@difizen/mana-core
Version:
434 lines (400 loc) • 13.3 kB
text/typescript
import {
Emitter,
WaitUntilEvent,
Disposable,
DisposableCollection,
} from '@difizen/mana-common';
import { prop } from '@difizen/mana-observable';
import type { Contribution } from '@difizen/mana-syringe';
import { Syringe } from '@difizen/mana-syringe';
import { contrib, singleton } from '@difizen/mana-syringe';
import { ApplicationContribution } from '../application/application';
import type {
CommandEvent,
CommandHandler,
CommandHandlerWithContext,
ActiveHandler,
EnabledHandler,
VisibleHandler,
WillExecuteCommandEvent,
ExecuteHandler,
} from './command-protocol';
import { Command, CommandService } from './command-protocol';
export const CommandContribution = Syringe.defineToken('CommandContribution');
/**
* The command contribution should be implemented to register custom commands and handler.
*/
export type CommandContribution = {
/**
* Register commands and handlers.
*/
registerCommands: (commands: CommandRegistry) => void;
};
/**
* The command registry manages commands and handlers.
*/
export class CommandRegistry implements CommandService, ApplicationContribution {
readonly commandMap: Record<string, Command> = {};
readonly ctxMap: Record<string, any> = {};
protected readonly _handlers: Record<string, CommandHandler[]> = {};
protected readonly toUnregisterCommands = new Map<string, Disposable>();
// List of recently used commands.
protected recent: Command[] = [];
protected readonly onWillExecuteCommandEmitter =
new Emitter<WillExecuteCommandEvent>();
readonly onWillExecuteCommand = this.onWillExecuteCommandEmitter.event;
protected readonly onDidExecuteCommandEmitter = new Emitter<CommandEvent>();
readonly onDidExecuteCommand = this.onDidExecuteCommandEmitter.event;
protected readonly contributionProvider: Contribution.Provider<CommandContribution>;
constructor(
contributionProvider: Contribution.Provider<CommandContribution>,
) {
this.contributionProvider = contributionProvider;
}
onStart(): void {
const contributions = this.contributionProvider.getContributions();
for (const contribution of contributions) {
contribution.registerCommands(this);
}
}
/**
* Register the given command and handler if present.
*
* Throw if a command is already registered for the given command identifier.
*/
registerCommand(command: Command, handler?: CommandHandler): Disposable {
if (this.commandMap[command.id]) {
console.warn(`A command ${command.id} is already registered.`);
return Disposable.NONE;
}
const toDispose = new DisposableCollection(this.doRegisterCommand(command));
if (handler) {
toDispose.push(this.registerHandler(command.id, handler));
}
this.toUnregisterCommands.set(command.id, toDispose);
toDispose.push(
Disposable.create(() => this.toUnregisterCommands.delete(command.id)),
);
return toDispose;
}
/**
* Register the given command with context, and handler if present.
*
* Throw if a command is already registered for the given command identifier.
*/
registerCommandWithContext<T = any>(
command: Command,
ctx: T,
handler?: CommandHandlerWithContext<T>,
): Disposable {
const toDispose = new DisposableCollection();
if (this.commandMap[command.id] && !this.ctxMap[command.id]) {
console.warn(
`A command ${command.id} is already registered and has no registered context.`,
);
return Disposable.NONE;
}
toDispose.push(this.registerCommand(command, handler));
toDispose.push(this.doRegisterCommandCtx(command, ctx));
return toDispose;
}
protected doRegisterCommandCtx(command: Command, ctx: any): Disposable {
this.ctxMap[command.id] = ctx;
return {
dispose: () => {
delete this.ctxMap[command.id];
},
};
}
protected doRegisterCommand(command: Command): Disposable {
this.commandMap[command.id] = command;
return {
dispose: () => {
delete this.commandMap[command.id];
},
};
}
/**
* Unregister command from the registry
*
* @param command
*/
unregisterCommand(command: Command): void;
/**
* Unregister command from the registry
*
* @param id
*/
// eslint-disable-next-line @typescript-eslint/unified-signatures
unregisterCommand(id: string): void;
unregisterCommand(commandOrId: Command | string): void {
const id = Command.is(commandOrId) ? commandOrId.id : commandOrId;
const toUnregister = this.toUnregisterCommands.get(id);
if (toUnregister) {
toUnregister.dispose();
}
}
/**
* Register the given handler for the given command identifier.
*
* If there is already a handler for the given command
* then the given handler is registered as more specific, and
* has higher priority during enablement, visibility and toggle state evaluations.
*/
registerHandler(commandId: string, handler: CommandHandler): Disposable {
let handlers = this._handlers[commandId];
if (!handlers) {
// eslint-disable-next-line no-multi-assign
this._handlers[commandId] = handlers = [];
}
handlers.unshift(handler);
return {
dispose: () => {
const idx = handlers.indexOf(handler);
if (idx >= 0) {
handlers.splice(idx, 1);
}
},
};
}
/**
* Test whether there is an active handler for the given command.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isEnabled(command: string, ...args: any[]): boolean {
return typeof this.getEnableHandler(command, ...args) !== 'undefined';
}
/**
* Test whether there is a visible handler for the given command.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isVisible(command: string, ...args: any[]): boolean {
return typeof this.getVisibleHandler(command, ...args) !== 'undefined';
}
/**
* Test whether there is a active handler for the given command.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isActive(command: string, ...args: any[]): boolean {
return typeof this.getActiveHandler(command, ...args) !== 'undefined';
}
toContextArgs(commandId: string, ...args: any[]): any[] {
const ctx = this.ctxMap[commandId];
if (ctx) {
return [ctx, ...args];
}
return args;
}
/**
* Test whether there is an active handler for the given command.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isEnabledByHandler(
handler: EnabledHandler,
command: string,
...args: any[]
): boolean {
const contextArgs = this.toContextArgs(command, ...args);
if (handler.isEnabled) {
return handler.isEnabled(...contextArgs);
}
return typeof this.getEnableHandler(command, ...args) !== 'undefined';
}
/**
* Test whether there is a visible handler for the given command.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isVisibleByHandler(
handler: VisibleHandler,
command: string,
...args: any[]
): boolean {
const contextArgs = this.toContextArgs(command, ...args);
if (handler.isVisible) {
return handler.isVisible(...contextArgs);
}
return typeof this.getVisibleHandler(command, ...args) !== 'undefined';
}
/**
* Test whether there is a active handler for the given command.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isActiveByHandler(handler: ActiveHandler, command: string, ...args: any[]): boolean {
const contextArgs = this.toContextArgs(command, ...args);
if (handler.isActive) {
return handler.isActive(...contextArgs);
}
return typeof this.getActiveHandler(command, ...args) !== 'undefined';
}
/**
* Execute the given handler for the given command and arguments.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
executeCommandByHandler<T>(
handler: ExecuteHandler,
command: string,
...args: any[]
): Promise<T | undefined> {
const contextArgs = this.toContextArgs(command, ...args);
if (handler.execute) {
return handler.execute(...contextArgs);
}
return this.executeCommand(command, ...args);
}
/**
* Execute the active handler for the given command and arguments.
*
* Reject if a command cannot be executed.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async executeCommand<T>(commandId: string, ...args: any[]): Promise<T | undefined> {
const handler = this.getEnableHandler(commandId, ...args);
if (handler) {
const contextArgs = this.toContextArgs(commandId, ...args);
await this.fireWillExecuteCommand(commandId, contextArgs);
const result = await handler.execute(...contextArgs);
this.onDidExecuteCommandEmitter.fire({ commandId, args: contextArgs });
return result;
}
throw Object.assign(
new Error(
`The command '${commandId}' cannot be executed. There are no active handlers available for the command.`,
),
{ code: 'NO_ACTIVE_HANDLER' },
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected async fireWillExecuteCommand(
commandId: string,
args: any[] = [],
): Promise<void> {
await WaitUntilEvent.fire(
this.onWillExecuteCommandEmitter,
{ commandId, args },
30000,
);
}
/**
* Get a visible handler for the given command or `undefined`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getVisibleHandler(commandId: string, ...args: any[]): CommandHandler | undefined {
const contextArgs = this.toContextArgs(commandId, ...args);
const handlers = this._handlers[commandId];
if (handlers) {
for (const handler of handlers) {
try {
if (!handler.isVisible || handler.isVisible(...contextArgs)) {
return handler;
}
} catch (error) {
console.error(error);
}
}
}
return undefined;
}
/**
* Get an enable handler for the given command or `undefined`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getEnableHandler(commandId: string, ...args: any[]): CommandHandler | undefined {
const contextArgs = this.toContextArgs(commandId, ...args);
const handlers = this._handlers[commandId];
if (handlers) {
for (const handler of handlers) {
try {
if (!handler.isEnabled || handler.isEnabled(...contextArgs)) {
return handler;
}
} catch (error) {
console.error(error);
}
}
}
return undefined;
}
/**
* Get an active handler for the given command or `undefined`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getActiveHandler(commandId: string, ...args: any[]): CommandHandler | undefined {
const contextArgs = this.toContextArgs(commandId, ...args);
const handlers = this._handlers[commandId];
if (handlers) {
for (const handler of handlers) {
try {
if (handler.isActive && handler.isActive(...contextArgs)) {
return handler;
}
} catch (error) {
console.error(error);
}
}
}
return undefined;
}
/**
* Returns with all handlers for the given command. If the command does not have any handlers,
* or the command is not registered, returns an empty array.
*/
getAllHandlers(commandId: string): CommandHandler[] {
const handlers = this._handlers[commandId];
return handlers ? handlers.slice() : [];
}
/**
* Get all registered commands.
*/
get commands(): Command[] {
const commands: Command[] = [];
for (const id of this.commandIds) {
const cmd = this.getCommand(id);
if (cmd) {
commands.push(cmd);
}
}
return commands;
}
/**
* Get a command for the given command identifier.
*/
getCommand(id: string): Command | undefined {
return this.commandMap[id];
}
/**
* Get all registered commands identifiers.
*/
get commandIds(): string[] {
return Object.keys(this.commandMap);
}
/**
* Adds a command to recently used list.
* Prioritizes commands that were recently executed to be most recent.
*
* @param recent a recent command, or array of recent commands.
*/
addRecentCommand(recent: Command | Command[]): void {
if (Array.isArray(recent)) {
recent.forEach((command: Command) => this.addRecentCommand(command));
} else {
// Determine if the command currently exists in the recently used list.
const index = this.recent.findIndex((command: Command) =>
Command.equals(recent, command),
);
// If the command exists, remove it from the array so it can later be placed at the top.
if (index >= 0) {
this.recent.splice(index, 1);
}
// Add the recent command to the beginning of the array (most recent).
this.recent.unshift(recent);
}
}
/**
* Clear the list of recently used commands.
*/
clearCommandHistory(): void {
this.recent = [];
}
}