Files
univer/packages/core/src/services/command/command.service.ts
T
白熱 ee85ccbef9 chore: migrate to eslint 8+ (#1235)
* chore: migrate to eslint 8+

* chore: remove unused deps

* fix: fix type check

* chore(eslint): add missing rules
2024-01-24 10:57:03 +08:00

476 lines
15 KiB
TypeScript

/**
* Copyright 2023-present DreamNum Inc.
*
* 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 type { IAccessor, IDisposable } from '@wendellhu/redi';
import { createIdentifier, Inject, Injector } from '@wendellhu/redi';
import { findLast, remove } from '../../common/array';
import { sequence, sequenceAsync } from '../../common/sequence';
import { DisposableCollection, toDisposable } from '../../shared/lifecycle';
import type { IKeyValue } from '../../shared/types';
import { IContextService } from '../context/context.service';
import { ILogService } from '../log/log.service';
export const enum CommandType {
/** Command could generate some operations or mutations. */
COMMAND = 0,
/** An operation that do not require conflict resolve. */
OPERATION = 1,
/** An operation that need to be resolved before applied on peer client. */
MUTATION = 2,
}
export interface ICommand<P extends object = object, R = boolean> {
/**
* ${businessName}.${type}.${name}
*/
readonly id: string;
readonly type: CommandType;
handler(accessor: IAccessor, params?: P): Promise<R> | R;
/**
* When this command is unregistered, this function would be called.
*
* @deprecated
*/
onDispose?: () => void;
}
export interface IMultiCommand<P extends object = object, R = boolean> extends ICommand<P, R> {
name: string;
multi: true;
priority: number;
preconditions?: (contextService: IContextService) => boolean;
}
export interface IMutationCommonParams {
trigger?: string;
}
/**
* Mutation would change the model of Univer applications.
*/
export interface IMutation<P extends object, R = boolean> extends ICommand<P, R> {
type: CommandType.MUTATION;
/**
* Mutations must be a sync process.
* @param accessor
* @param params Params of the mutation. A mutation must has params.
*/
handler(accessor: IAccessor, params: P): R;
}
/**
* Operation would change the state of Univer applications. State should only be in memory and does not
* require conflicting resolution.
*/
export interface IOperation<P extends object = object, R = boolean> extends ICommand<P, R> {
type: CommandType.OPERATION;
/**
* Operations must be a sync process.
* @param accessor
* @param params Params of the operation. A operation must has params.
*/
handler(accessor: IAccessor, params: P): R;
}
/**
* The command info, only a command id and responsible params
*/
// TODO: change object to serializable interface type
export interface ICommandInfo<T extends object = object> {
id: string;
type?: CommandType;
/**
* Args should be serializable.
*/
params?: T;
}
export interface IMutationInfo<T extends object = object> {
id: string;
type?: CommandType.MUTATION;
params: T;
}
export interface IOperationInfo<T extends object = object> {
id: string;
type?: CommandType.OPERATION;
params: T;
}
export interface IExecutionOptions {
/** This mutation should only be executed on the local machine, and not synced to replicas. */
onlyLocal?: boolean;
/** This command is from collaboration peers. */
fromCollab?: boolean;
[key: PropertyKey]: string | number | boolean | undefined;
}
export type CommandListener = (commandInfo: Readonly<ICommandInfo>, options?: IExecutionOptions) => void;
export interface ICommandService {
registerCommand(command: ICommand): IDisposable;
registerMultipleCommand(command: ICommand): IDisposable;
executeCommand<P extends object = object, R = boolean>(
id: string,
params?: P,
options?: IExecutionOptions
): Promise<R>;
syncExecuteCommand<P extends object = object, R = boolean>(id: string, params?: P, options?: IExecutionOptions): R;
/**
* Register a callback function that will be executed after a command is executed.
*/
onCommandExecuted(listener: CommandListener): IDisposable;
/**
* Register a callback function that will be executed before a command is executed.
* @param listener
*/
beforeCommandExecuted(listener: CommandListener): IDisposable;
}
export const ICommandService = createIdentifier<ICommandService>('anywhere.command-service');
/**
* The registry of commands.
*/
export class CommandRegistry {
private readonly _commands = new Map<string, ICommand>();
registerCommand(command: ICommand): IDisposable {
if (this._commands.has(command.id)) {
throw new Error(`[CommandRegistry]: command "${command.id}" has been registered before.`);
}
this._commands.set(command.id, command);
return toDisposable(() => {
this._commands.delete(command.id);
command.onDispose?.();
});
}
getCommand(id: string): [ICommand] | null {
if (!this._commands.has(id)) {
return null;
}
return [this._commands.get(id)!];
}
}
interface ICommandExecutionStackItem extends ICommandInfo {}
export class CommandService implements ICommandService {
private readonly _commandRegistry: CommandRegistry;
private readonly _beforeCommandExecutionListeners: CommandListener[] = [];
private readonly _commandExecutedListeners: CommandListener[] = [];
private _multiCommandDisposables = new Map<string, IDisposable>();
private _commandExecutingLevel = 0;
private _commandExecutionStack: ICommandExecutionStackItem[] = [];
constructor(
@Inject(Injector) private readonly _injector: Injector,
@ILogService private readonly _logService: ILogService
) {
this._commandRegistry = new CommandRegistry();
this._registerCommand(NilCommand);
}
registerCommand(command: ICommand): IDisposable {
return this._registerCommand(command);
}
registerMultipleCommand(command: ICommand): IDisposable {
return this._registerMultiCommand(command);
}
beforeCommandExecuted(listener: CommandListener): IDisposable {
if (this._beforeCommandExecutionListeners.indexOf(listener) === -1) {
this._beforeCommandExecutionListeners.push(listener);
return toDisposable(() => {
const index = this._beforeCommandExecutionListeners.indexOf(listener);
this._beforeCommandExecutionListeners.splice(index, 1);
});
}
throw new Error('[CommandService]: could not add a listener twice.');
}
onCommandExecuted(listener: (commandInfo: ICommandInfo) => void): IDisposable {
if (this._commandExecutedListeners.indexOf(listener) === -1) {
this._commandExecutedListeners.push(listener);
return toDisposable(() => {
const index = this._commandExecutedListeners.indexOf(listener);
this._commandExecutedListeners.splice(index, 1);
});
}
throw new Error('[CommandService]: could not add a listener twice.');
}
async executeCommand<P extends object = object, R = boolean>(
id: string,
params?: P,
options?: IExecutionOptions
): Promise<R> {
const item = this._commandRegistry.getCommand(id);
if (item) {
const [command] = item;
const commandInfo: ICommandInfo = {
id: command.id,
type: command.type,
params,
};
const stackItemDisposable = this._pushCommandExecutionStack(commandInfo);
this._beforeCommandExecutionListeners.forEach((listener) => listener(commandInfo, options));
const result = await this._execute<P, R>(command as ICommand<P, R>, params);
this._commandExecutedListeners.forEach((listener) => listener(commandInfo, options));
stackItemDisposable.dispose();
return result;
}
throw new Error(`[CommandService]: command "${id}" is not registered.`);
}
syncExecuteCommand<P extends object = object, R = boolean>(
id: string,
params?: P | undefined,
options?: IExecutionOptions
): R {
const item = this._commandRegistry.getCommand(id);
if (item) {
const [command] = item;
const commandInfo: ICommandInfo = {
id: command.id,
type: command.type,
params,
};
// If the executed command is of type `Mutation`, we should add a trigger params,
// whose value is the command's ID that triggers the mutation.
if (command.type === CommandType.MUTATION) {
const triggerCommand = findLast(
this._commandExecutionStack,
(item) => item.type === CommandType.COMMAND
);
if (triggerCommand) {
commandInfo.params = commandInfo.params ?? {};
(commandInfo.params as IMutationCommonParams).trigger = triggerCommand.id;
}
}
const stackItemDisposable = this._pushCommandExecutionStack(commandInfo);
this._beforeCommandExecutionListeners.forEach((listener) => listener(commandInfo, options));
const result = this._syncExecute<P, R>(command as ICommand<P, R>, params);
this._commandExecutedListeners.forEach((listener) => listener(commandInfo, options));
stackItemDisposable.dispose();
return result;
}
throw new Error(`[CommandService]: command "${id}" is not registered.`);
}
private _pushCommandExecutionStack(stackItem: ICommandExecutionStackItem): IDisposable {
this._commandExecutionStack.push(stackItem);
return toDisposable(() => remove(this._commandExecutionStack, stackItem));
}
private _registerCommand(command: ICommand): IDisposable {
return this._commandRegistry.registerCommand(command);
}
private _registerMultiCommand(command: ICommand): IDisposable {
// compose a multi command and register it
const registry = this._commandRegistry.getCommand(command.id);
let multiCommand: MultiCommand;
if (!registry) {
const disposableCollection = new DisposableCollection();
multiCommand = new MultiCommand(command.id);
disposableCollection.add(this._commandRegistry.registerCommand(multiCommand));
disposableCollection.add(
toDisposable(() => {
this._multiCommandDisposables.delete(command.id);
})
);
this._multiCommandDisposables.set(command.id, disposableCollection);
} else {
if ((registry[0] as IKeyValue).multi !== true) {
throw new Error('Command has registered as a single command.');
} else {
multiCommand = registry[0] as MultiCommand;
}
}
const implementationDisposable = multiCommand.registerImplementation(command as IMultiCommand);
return toDisposable(() => {
implementationDisposable.dispose();
if (!multiCommand.hasImplementations()) {
this._multiCommandDisposables.get(command.id)?.dispose();
}
});
}
private async _execute<P extends object, R = boolean>(command: ICommand<P, R>, params?: P): Promise<R> {
this._logService.debug(
'[CommandService]',
`${'|-'.repeat(Math.max(this._commandExecutingLevel, 0))}executing command "${command.id}"`
);
this._commandExecutingLevel++;
let result: R | boolean;
try {
result = await this._injector.invoke(command.handler, params);
this._commandExecutingLevel--;
} catch (e) {
result = false;
this._commandExecutingLevel = 0;
throw e;
}
return result;
}
private _syncExecute<P extends object, R = boolean>(command: ICommand<P, R>, params?: P): R {
this._logService.debug(
'[CommandService]',
`${'|-'.repeat(Math.max(0, this._commandExecutingLevel))}executing command "${command.id}".`
);
this._commandExecutingLevel++;
let result: R | boolean;
try {
result = this._injector.invoke(command.handler, params) as R;
if (result instanceof Promise) {
throw new TypeError('[CommandService]: Command handler should not return a promise.');
}
this._commandExecutingLevel--;
} catch (e) {
result = false;
this._commandExecutingLevel = 0;
throw e;
}
return result;
}
}
class MultiCommand implements IMultiCommand {
readonly name: string;
readonly multi = true;
readonly type = CommandType.COMMAND;
readonly priority: number = 0;
private readonly _implementations: Array<{ command: IMultiCommand }> = [];
constructor(readonly id: string) {
this.name = id;
}
registerImplementation(implementation: IMultiCommand): IDisposable {
const registry = { command: implementation };
this._implementations.push(registry);
this._implementations.sort((a, b) => b.command.priority - a.command.priority);
return toDisposable(() => {
const index = this._implementations.indexOf(registry);
this._implementations.splice(index, 1);
implementation.onDispose?.();
});
}
hasImplementations(): boolean {
return this._implementations.length > 0;
}
handler = async (accessor: IAccessor, params?: object | undefined): Promise<boolean> => {
if (!this._implementations.length) {
return false;
}
const logService = accessor.get(ILogService);
const contextService = accessor.get(IContextService);
const injector = accessor.get(Injector);
for (const item of this._implementations) {
const preconditions = item.command.preconditions;
if (!preconditions || (preconditions && preconditions(contextService))) {
logService.debug('[MultiCommand]', `executing implementation "${item.command.name}".`);
const result = await injector.invoke(item.command.handler, params);
if (result) {
return true;
}
}
}
return false;
};
}
export function sequenceExecute(tasks: ICommandInfo[], commandService: ICommandService, options?: IExecutionOptions) {
const taskFns = tasks.map((task) => () => commandService.syncExecuteCommand(task.id, task.params, options));
return sequence(taskFns);
}
export function sequenceExecuteAsync(
tasks: ICommandInfo[],
commandService: ICommandService,
options?: IExecutionOptions
) {
const promises = tasks.map((task) => () => commandService.executeCommand(task.id, task.params, options));
return sequenceAsync(promises);
}
export const NilCommand: ICommand = {
id: 'nil',
type: CommandType.COMMAND,
handler: () => true,
};