From d207795222ca68e9d1f3ce9ed35c6e8ebd8d7f21 Mon Sep 17 00:00:00 2001 From: paradox Date: Mon, 25 Dec 2023 00:11:50 +0000 Subject: [PATCH] feat: rework interface data (#7) --- src/client/core.ts | 27 ++++-- src/client/dispatcher.ts | 26 +++--- src/client/interface/app/app.tsx | 6 +- .../components/terminal/Terminal.tsx | 4 +- .../components/terminal/TerminalTextField.tsx | 5 +- .../components/terminal/TerminalWindow.tsx | 40 ++------ .../interface/providers/commanderProvider.tsx | 75 +++++++++++++++ .../interface/providers/dataProvider.tsx | 29 ------ .../interface/providers/rootProvider.tsx | 10 +- .../interface/store/app/appSelectors.ts | 2 - src/client/interface/store/app/appSlice.ts | 15 +-- src/client/registry.ts | 93 +++++++++++-------- src/client/types.ts | 31 +++++-- src/server/index.ts | 1 - src/shared/core/registry.ts | 54 ++++++----- 15 files changed, 234 insertions(+), 184 deletions(-) create mode 100644 src/client/interface/providers/commanderProvider.tsx delete mode 100644 src/client/interface/providers/dataProvider.tsx diff --git a/src/client/core.ts b/src/client/core.ts index 64306508..b6985b98 100644 --- a/src/client/core.ts +++ b/src/client/core.ts @@ -3,11 +3,17 @@ import { mergeDeep } from "@rbxts/sift/out/Dictionary"; import { ClientDispatcher } from "./dispatcher"; import { DEFAULT_OPTIONS } from "./options"; import { ClientRegistry } from "./registry"; +import { CommanderEvents } from "./types"; export namespace CommanderClient { let started = false; - const registryInstance = new ClientRegistry(); - const dispatcherInstance = new ClientDispatcher(registryInstance); + const events: CommanderEvents = { + historyUpdated: new Instance("BindableEvent"), + commandAdded: new Instance("BindableEvent"), + groupAdded: new Instance("BindableEvent"), + }; + const registryInstance = new ClientRegistry(events); + const dispatcherInstance = new ClientDispatcher(registryInstance, events); let optionsObject = DEFAULT_OPTIONS; const IS_CLIENT = RunService.IsClient(); @@ -34,18 +40,23 @@ export namespace CommanderClient { callback(registryInstance); await registryInstance.sync(); - - registryInstance.freeze(); started = true; if (options.app !== undefined) { options.app({ options: optionsObject, execute: (path, text) => dispatcherInstance.run(path, text), - commands: registryInstance.getCommandOptions(), - groups: registryInstance.getGroupOptions(), - history: dispatcherInstance.getHistory(), - onHistoryUpdated: dispatcherInstance.getHistorySignal(), + addHistoryEntry: (entry) => dispatcherInstance.addHistoryEntry(entry), + initialData: { + commands: registryInstance.getCommandOptions(), + groups: registryInstance.getGroupOptions(), + history: dispatcherInstance.getHistory(), + }, + events: { + historyUpdated: events.historyUpdated.Event, + commandAdded: events.commandAdded.Event, + groupAdded: events.groupAdded.Event, + }, }); } } diff --git a/src/client/dispatcher.ts b/src/client/dispatcher.ts index da7856b4..1125f847 100644 --- a/src/client/dispatcher.ts +++ b/src/client/dispatcher.ts @@ -1,14 +1,20 @@ import { Players } from "@rbxts/services"; -import { CommandPath } from "../shared"; +import { BaseRegistry, CommandPath } from "../shared"; import { BaseDispatcher } from "../shared/core/dispatcher"; import { DEFAULT_HISTORY_LENGTH } from "./options"; -import { ClientOptions, HistoryEntry } from "./types"; +import { ClientOptions, CommanderEvents, HistoryEntry } from "./types"; export class ClientDispatcher extends BaseDispatcher { private readonly history: HistoryEntry[] = []; - private readonly historyEvent = new Instance("BindableEvent"); private maxHistoryLength = DEFAULT_HISTORY_LENGTH; + constructor( + registry: BaseRegistry, + private readonly events: CommanderEvents, + ) { + super(registry); + } + /** * Initialises the client dispatcher. * @@ -60,22 +66,12 @@ export class ClientDispatcher extends BaseDispatcher { return this.history; } - /** - * Gets the history signal, which will be fired each time a new - * {@link HistoryEntry} is added. - * - * @returns The history signal - */ - getHistorySignal() { - return this.historyEvent.Event; - } - - private addHistoryEntry(entry: HistoryEntry) { + addHistoryEntry(entry: HistoryEntry) { if (this.history.size() >= this.maxHistoryLength) { this.history.remove(0); } this.history.push(entry); - this.historyEvent.Fire(entry); + this.events.historyUpdated.Fire(this.history); } } diff --git a/src/client/interface/app/app.tsx b/src/client/interface/app/app.tsx index 5829e41d..e4a323bc 100644 --- a/src/client/interface/app/app.tsx +++ b/src/client/interface/app/app.tsx @@ -3,7 +3,7 @@ import "./config"; import { createPortal, createRoot } from "@rbxts/react-roblox"; import Roact, { StrictMode } from "@rbxts/roact"; import { Players } from "@rbxts/services"; -import { AppData } from "../../types"; +import { AppContext } from "../../types"; import { Layer } from "../components/interface/Layer"; import Terminal from "../components/terminal/Terminal"; import { RootProvider } from "../providers/rootProvider"; @@ -18,7 +18,7 @@ function getSuggestion(query: SuggestionQuery) { return getCommandSuggestion(query.parentPath, query.text); } -export function CommanderApp(data: AppData) { +export function CommanderApp(data: AppContext) { const root = createRoot(new Instance("Folder")); const target = Players.LocalPlayer.WaitForChild("PlayerGui"); @@ -27,7 +27,7 @@ export function CommanderApp(data: AppData) { diff --git a/src/client/interface/components/terminal/Terminal.tsx b/src/client/interface/components/terminal/Terminal.tsx index 8cb5edb0..2a135dd1 100644 --- a/src/client/interface/components/terminal/Terminal.tsx +++ b/src/client/interface/components/terminal/Terminal.tsx @@ -4,7 +4,7 @@ import Roact, { useContext, useMemo } from "@rbxts/roact"; import { GuiService, UserInputService } from "@rbxts/services"; import { useRem } from "../../hooks/useRem"; import { useStore } from "../../hooks/useStore"; -import { DataContext } from "../../providers/dataProvider"; +import { CommanderContext } from "../../providers/commanderProvider"; import { selectVisible } from "../../store/app"; import { Group } from "../interface/Group"; import { TerminalWindow } from "./TerminalWindow"; @@ -12,7 +12,7 @@ import { SuggestionList } from "./suggestion"; export default function Terminal() { const rem = useRem(); - const data = useContext(DataContext); + const data = useContext(CommanderContext); const store = useStore(); const visible = useSelector(selectVisible); diff --git a/src/client/interface/components/terminal/TerminalTextField.tsx b/src/client/interface/components/terminal/TerminalTextField.tsx index f768c2ac..393efd86 100644 --- a/src/client/interface/components/terminal/TerminalTextField.tsx +++ b/src/client/interface/components/terminal/TerminalTextField.tsx @@ -21,7 +21,7 @@ import { fonts } from "../../constants/fonts"; import { palette } from "../../constants/palette"; import { useRem } from "../../hooks/useRem"; import { useStore } from "../../hooks/useStore"; -import { DataContext } from "../../providers/dataProvider"; +import { CommanderContext } from "../../providers/commanderProvider"; import { SuggestionContext } from "../../providers/suggestionProvider"; import { selectVisible } from "../../store/app"; import { getArgumentNames } from "../../util/argument"; @@ -47,7 +47,7 @@ export function TerminalTextField({ }: TerminalTextFieldProps) { const rem = useRem(); const ref = useRef(); - const data = useContext(DataContext); + const data = useContext(CommanderContext); const suggestion = useContext(SuggestionContext).suggestion; const store = useStore(); @@ -143,6 +143,7 @@ export function TerminalTextField({ const suggestionTextParts = suggestionTextValue .gsub("%s+", " ")[0] .split(" "); + const nextCommand = data.commands.get( formatPartsAsPath(suggestionTextParts), ); diff --git a/src/client/interface/components/terminal/TerminalWindow.tsx b/src/client/interface/components/terminal/TerminalWindow.tsx index cd96df9c..df1c8dd0 100644 --- a/src/client/interface/components/terminal/TerminalWindow.tsx +++ b/src/client/interface/components/terminal/TerminalWindow.tsx @@ -1,24 +1,19 @@ -import { useEventListener, useMountEffect } from "@rbxts/pretty-react-hooks"; -import { useSelector } from "@rbxts/react-reflex"; import Roact, { useContext, useEffect, useMemo, useState } from "@rbxts/roact"; import { TextService } from "@rbxts/services"; import { copy, pop, slice } from "@rbxts/sift/out/Array"; -import { copyDeep } from "@rbxts/sift/out/Dictionary"; import { ImmutableCommandPath } from "../../../../shared"; import { endsWithSpace, formatPartsAsPath, splitStringBySpace, } from "../../../../shared/util/string"; -import { DEFAULT_HISTORY_LENGTH } from "../../../options"; import { DEFAULT_FONT } from "../../constants/fonts"; import { palette } from "../../constants/palette"; import { useMotion } from "../../hooks/useMotion"; import { useRem } from "../../hooks/useRem"; import { useStore } from "../../hooks/useStore"; -import { DataContext } from "../../providers/dataProvider"; +import { CommanderContext } from "../../providers/commanderProvider"; import { SuggestionContext } from "../../providers/suggestionProvider"; -import { selectHistory } from "../../store/app"; import { HistoryLineData } from "../../types"; import { Frame } from "../interface/Frame"; import { Padding } from "../interface/Padding"; @@ -37,10 +32,9 @@ function getParentPath(parts: string[], atNextPart: boolean) { export function TerminalWindow() { const rem = useRem(); const store = useStore(); - const data = useContext(DataContext); + const data = useContext(CommanderContext); const suggestionData = useContext(SuggestionContext); - const history = useSelector(selectHistory); const [historyData, setHistoryData] = useState({ lines: [], height: 0, @@ -61,26 +55,15 @@ export function TerminalWindow() { }, [rem]); // Handle history updates - useMountEffect(() => { - store.setHistory(copyDeep(data.history)); - }); - - useEventListener(data.onHistoryUpdated, (entry) => { - store.addHistoryEntry( - entry, - data.options.historyLength ?? DEFAULT_HISTORY_LENGTH, - ); - }); - useEffect(() => { - const historySize = history.size(); + const historySize = data.history.size(); let totalHeight = historySize > 0 ? rem(0.5) + (historySize - 1) * rem(0.5) : 0; textBoundsParams.Size = rem(1.5); const historyLines: HistoryLineData[] = []; - for (const entry of history) { + for (const entry of data.history) { textBoundsParams.Text = entry.text; const textSize = TextService.GetTextBoundsAsync(textBoundsParams); totalHeight += textSize.Y; @@ -94,7 +77,7 @@ export function TerminalWindow() { lines: historyLines, height: totalHeight, }); - }, [history, rem]); + }, [data.history, rem]); return ( Promise; + commands: Map; + groups: Map; + history: HistoryEntry[]; + addHistoryEntry: (entry: HistoryEntry) => void; +} + +const DEFAULT_EXECUTE_CALLBACK = async () => ({ + text: "Command executed.", + success: true, + sentAt: DateTime.now().UnixTimestamp, +}); + +export const DEFAULT_COMMANDER_CONTEXT: CommanderContextData = { + options: DEFAULT_OPTIONS, + execute: DEFAULT_EXECUTE_CALLBACK, + commands: new Map(), + groups: new Map(), + history: [], + addHistoryEntry: () => {}, +}; + +export interface CommanderProviderProps extends Roact.PropsWithChildren { + value: AppContext; +} + +export const CommanderContext = createContext( + DEFAULT_COMMANDER_CONTEXT, +); + +export function CommanderProvider({ value, children }: CommanderProviderProps) { + const [history, setHistory] = useState(value.initialData.history); + const [commands, setCommands] = useState(value.initialData.commands); + const [groups, setGroups] = useState(value.initialData.groups); + + useEventListener(value.events.historyUpdated, (entries) => { + setHistory(copyDeep(entries)); + }); + + useEventListener(value.events.commandAdded, (key, command) => { + const newData = copyDeep(commands); + newData.set(key, command); + setCommands(newData); + }); + + useEventListener(value.events.groupAdded, (key, group) => { + const newData = copyDeep(groups); + groups.set(key, group); + setGroups(newData); + }); + + return ( + + {children} + + ); +} diff --git a/src/client/interface/providers/dataProvider.tsx b/src/client/interface/providers/dataProvider.tsx deleted file mode 100644 index 17e813c5..00000000 --- a/src/client/interface/providers/dataProvider.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import Roact, { createContext } from "@rbxts/roact"; -import { DEFAULT_OPTIONS } from "../../options"; -import { AppData } from "../../types"; - -export interface DataProviderProps extends Roact.PropsWithChildren { - data: AppData; -} - -export const DEFAULT_DATA: AppData = { - options: DEFAULT_OPTIONS, - execute: async () => { - return { - text: "Command executed.", - success: true, - sentAt: DateTime.now().UnixTimestamp, - }; - }, - - commands: new Map(), - groups: new Map(), - history: [], - onHistoryUpdated: new Instance("BindableEvent").Event, -}; - -export const DataContext = createContext(DEFAULT_DATA); - -export function DataProvider({ data, children }: DataProviderProps) { - return {children}; -} diff --git a/src/client/interface/providers/rootProvider.tsx b/src/client/interface/providers/rootProvider.tsx index 3dcfd8f0..9fb5fde6 100644 --- a/src/client/interface/providers/rootProvider.tsx +++ b/src/client/interface/providers/rootProvider.tsx @@ -1,7 +1,7 @@ import { ReflexProvider } from "@rbxts/react-reflex"; import Roact from "@rbxts/roact"; import { store } from "../store"; -import { DataProvider, DataProviderProps } from "./dataProvider"; +import { CommanderProvider, CommanderProviderProps } from "./commanderProvider"; import { RemProvider, RemProviderProps } from "./remProvider"; import { SuggestionProvider, @@ -10,26 +10,26 @@ import { interface RootProviderProps extends RemProviderProps, - DataProviderProps, + CommanderProviderProps, SuggestionProviderProps {} export function RootProvider({ baseRem, - data, + value: data, getSuggestion, children, }: RootProviderProps) { return ( - + {children} - + ); diff --git a/src/client/interface/store/app/appSelectors.ts b/src/client/interface/store/app/appSelectors.ts index 3db3ef3c..1e9ec9f9 100644 --- a/src/client/interface/store/app/appSelectors.ts +++ b/src/client/interface/store/app/appSelectors.ts @@ -2,6 +2,4 @@ import { RootState } from ".."; export const selectVisible = (state: RootState) => state.app.visible; -export const selectHistory = (state: RootState) => state.app.history; - export const selectText = (state: RootState) => state.app.text; diff --git a/src/client/interface/store/app/appSlice.ts b/src/client/interface/store/app/appSlice.ts index df07c37e..63cd4f0b 100644 --- a/src/client/interface/store/app/appSlice.ts +++ b/src/client/interface/store/app/appSlice.ts @@ -1,12 +1,11 @@ import { createProducer } from "@rbxts/reflex"; -import { copy, copyDeep } from "@rbxts/sift/out/Array"; +import { copy } from "@rbxts/sift/out/Array"; import { ImmutableCommandPath } from "../../../../shared"; import { HistoryEntry } from "../../../types"; export interface AppState { visible: boolean; - history: HistoryEntry[]; commandHistory: string[]; commandHistoryIndex: number; @@ -21,7 +20,6 @@ export interface AppState { export const initialAppState: AppState = { visible: false, - history: [], commandHistory: [], commandHistoryIndex: -1, text: { @@ -48,17 +46,6 @@ function limitArray(array: T[], limit: number) { export const appSlice = createProducer(initialAppState, { setVisible: (state, visible: boolean) => ({ ...state, visible }), - addHistoryEntry: (state, entry: HistoryEntry, limit: number) => { - const history = copyDeep(state.history); - limitArray(history, limit); - history.push(entry); - - return { - ...state, - history, - }; - }, - addCommandHistory: (state, command: string, limit: number) => { const commandHistory = copy(state.commandHistory); limitArray(commandHistory, limit); diff --git a/src/client/registry.ts b/src/client/registry.ts index a58a0698..6ce01068 100644 --- a/src/client/registry.ts +++ b/src/client/registry.ts @@ -1,33 +1,61 @@ import { RunService } from "@rbxts/services"; import { copyDeep } from "@rbxts/sift/out/Dictionary"; import { CommandOptions, GroupOptions, ImmutableCommandPath } from "../shared"; -import { CommandGroup } from "../shared/core/command"; +import { BaseCommand, CommandGroup } from "../shared/core/command"; import { BaseRegistry } from "../shared/core/registry"; import { remotes } from "../shared/network"; import { ServerCommand } from "./command"; +import { CommanderEvents } from "./types"; export class ClientRegistry extends BaseRegistry { + private initialSyncReceived = false; + + constructor(private readonly events: CommanderEvents) { + super(); + } + init() { this.registerBuiltInTypes(); } async sync() { - let firstDispatch = false; + const syncedCommands = new Set(); + const syncedGroups = new Set(); + + const getGroupKey = (group: GroupOptions) => { + let groupName = group.name; + if (group.root !== undefined) { + groupName = `${group.root}/${groupName}`; + } + return groupName; + }; + remotes.sync.dispatch.connect((data) => { - if (!firstDispatch) { - firstDispatch = true; + if (!this.initialSyncReceived) this.initialSyncReceived = true; + + for (const [k] of data.commands) { + if (!syncedCommands.has(k)) continue; + data.commands.delete(k); } - this.registerServerGroups(data.groups); + this.registerGroups( + data.groups.filter((group) => !syncedGroups.has(getGroupKey(group))), + ); this.registerServerCommands(data.commands); + + for (const [k] of data.commands) { + syncedCommands.add(k); + } + + for (const group of data.groups) { + syncedGroups.add(getGroupKey(group)); + } }); remotes.sync.start.fire(); return new Promise((resolve) => { // Wait until dispatch has been received - while (!firstDispatch) { - RunService.Heartbeat.Wait(); - } + while (!this.initialSyncReceived) RunService.Heartbeat.Wait(); resolve(undefined); }) .timeout(5) @@ -46,43 +74,23 @@ export class ClientRegistry extends BaseRegistry { getGroupOptions() { const groupMap = new Map(); - for (const [k, v] of this.commands) { - groupMap.set(k, copyDeep(v.options as CommandOptions)); + for (const [k, v] of this.groups) { + groupMap.set(k, copyDeep(v.options as GroupOptions)); } return groupMap; } - private registerServerGroups(sharedGroups: GroupOptions[]) { - const childMap = new Map(); - for (const group of sharedGroups) { - if (group.root !== undefined) { - const childArray = childMap.get(group.root) ?? []; - childArray.push(group); - childMap.set(group.root, childArray); - continue; - } - - if (this.groups.has(group.name)) { - warn("Skipping duplicate server group:", group.name); - continue; - } - - this.validatePath(group.name, false); - this.groups.set(group.name, this.createGroup(group)); - } - - for (const [root, children] of childMap) { - const rootGroup = this.groups.get(root); - assert(rootGroup !== undefined, `Parent group '${root}' does not exist`); + protected updateCommandMap(key: string, command: BaseCommand): void { + super.updateCommandMap(key, command); + this.events.commandAdded.Fire( + key, + copyDeep(command.options as CommandOptions), + ); + } - for (const child of children) { - if (rootGroup.hasGroup(child.name)) { - warn(`Skipping duplicate server group in ${root}: ${child}`); - continue; - } - rootGroup.addGroup(this.createGroup(child)); - } - } + protected updateGroupMap(key: string, group: CommandGroup): void { + super.updateGroupMap(key, group); + this.events.groupAdded.Fire(key, copyDeep(group.options as GroupOptions)); } private registerServerCommands(commands: Map) { @@ -110,7 +118,10 @@ export class ClientRegistry extends BaseRegistry { this.validatePath(path, true); this.cachePath(commandPath); - this.commands.set(path, ServerCommand.create(this, commandPath, command)); + this.updateCommandMap( + path, + ServerCommand.create(this, commandPath, command), + ); } } } diff --git a/src/client/types.ts b/src/client/types.ts index 57921d48..90480a71 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -3,17 +3,34 @@ import { CommandOptions, CommandPath, GroupOptions } from "../shared"; export interface ClientOptions { historyLength?: number; activationKeys?: Enum.KeyCode[]; - app?: (data: AppData) => void; + app?: (data: AppContext) => void; } -export interface AppData { +export interface CommanderEvents { + historyUpdated: BindableEvent<(history: HistoryEntry[]) => void>; + commandAdded: BindableEvent<(key: string, command: CommandOptions) => void>; + groupAdded: BindableEvent<(key: string, group: GroupOptions) => void>; +} + +export type CommanderEventCallbacks = { + [K in keyof CommanderEvents]: CommanderEvents[K] extends BindableEvent< + infer R + > + ? RBXScriptSignal + : never; +}; + +export type AppContext = { options: ClientOptions; execute: (path: CommandPath, text: string) => Promise; - commands: Map; - groups: Map; - history: HistoryEntry[]; - onHistoryUpdated: RBXScriptSignal<(entry: HistoryEntry) => void>; -} + addHistoryEntry: (entry: HistoryEntry) => void; + initialData: { + commands: Map; + groups: Map; + history: HistoryEntry[]; + }; + events: CommanderEventCallbacks; +}; export interface HistoryEntry { text: string; diff --git a/src/server/index.ts b/src/server/index.ts index 8780d79d..4c635ff0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -32,7 +32,6 @@ export namespace CommanderServer { dispatcherInstance.init(); registryInstance.init(optionsObject); callback(registryInstance); - registryInstance.freeze(); started = true; } diff --git a/src/shared/core/registry.ts b/src/shared/core/registry.ts index 51f05118..4be2ac39 100644 --- a/src/shared/core/registry.ts +++ b/src/shared/core/registry.ts @@ -9,15 +9,13 @@ import { import { MetadataKey } from "./decorators"; import { CommandPath, ImmutableCommandPath } from "./path"; -const ROOT_NAME_KEY = "__root__"; - export abstract class BaseRegistry { + protected static readonly ROOT_KEY = "__root__"; protected readonly commands = new Map(); protected readonly groups = new Map(); protected readonly types = new Map>(); protected readonly registeredObjects = new Set(); protected cachedPaths = new Map(); - protected frozen = false; protected registerBuiltInTypes() { const builtInTypes = @@ -29,20 +27,12 @@ export abstract class BaseRegistry { this.registerContainer(builtInTypes); } - /** - * Freezes the registry, preventing any further registration. - */ - freeze() { - this.frozen = true; - } - /** * Registers a type from a given {@link TypeOptions}. * * @param typeOptions The type to register */ registerType(typeOptions: TypeOptions) { - assert(!this.frozen, "Registry frozen"); this.types.set(typeOptions.name, typeOptions); } @@ -52,7 +42,6 @@ export abstract class BaseRegistry { * @param types The types to register */ registerTypes(...types: TypeOptions[]) { - assert(!this.frozen, "Registry frozen"); for (const options of types) { this.registerType(options); } @@ -65,7 +54,6 @@ export abstract class BaseRegistry { * @param container The container containing {@link ModuleScript}s */ registerContainer(container: Instance) { - assert(!this.frozen, "Registry frozen"); for (const obj of container.GetChildren()) { if (!obj.IsA("ModuleScript")) { continue; @@ -89,7 +77,6 @@ export abstract class BaseRegistry { * @param container The {@link Instance} containing commands */ registerCommandsIn(container: Instance) { - assert(!this.frozen, "Registry frozen"); for (const obj of container.GetChildren()) { if (!obj.IsA("ModuleScript")) { return; @@ -158,15 +145,17 @@ export abstract class BaseRegistry { * @returns The paths that are children of the given path, or all paths */ getChildPaths(path?: CommandPath) { - return this.cachedPaths.get(path?.toString() ?? ROOT_NAME_KEY) ?? []; + return ( + this.cachedPaths.get(path?.toString() ?? BaseRegistry.ROOT_KEY) ?? [] + ); } protected cachePath(path: CommandPath) { - let cacheKey: string; + let cacheKey = BaseRegistry.ROOT_KEY; if (path.getSize() === 3) { if (!this.cachedPaths.has(path.getRoot())) { this.addCacheEntry( - ROOT_NAME_KEY, + BaseRegistry.ROOT_KEY, CommandPath.fromString(path.getRoot()), ); } @@ -174,8 +163,6 @@ export abstract class BaseRegistry { const childPath = path.slice(0, 1); this.addCacheEntry(path.getRoot(), childPath); cacheKey = childPath.toString(); - } else { - cacheKey = ROOT_NAME_KEY; } this.addCacheEntry(cacheKey, path); @@ -205,7 +192,7 @@ export abstract class BaseRegistry { [...commandData.guards], ); - this.commands.set(path.toString(), command); + this.updateCommandMap(path.toString(), command); if (group !== undefined) { group.addCommand(command); @@ -222,7 +209,7 @@ export abstract class BaseRegistry { ); const globalGroups = holderOptions?.globalGroups ?? []; if (holderOptions?.groups !== undefined) { - this.registerCommandGroups(holderOptions.groups); + this.registerGroups(holderOptions.groups); } for (const command of MetadataReflect.getOwnProperties(commandHolder)) { @@ -248,7 +235,7 @@ export abstract class BaseRegistry { } } - protected registerCommandGroups(groups: GroupOptions[]) { + protected registerGroups(groups: GroupOptions[]) { const childMap = new Map(); for (const group of groups) { if (group.root !== undefined) { @@ -258,9 +245,13 @@ export abstract class BaseRegistry { continue; } + if (this.groups.has(group.name)) { + warn("Skipping duplicate group:", group.name); + continue; + } + this.validatePath(group.name, false); - const groupObject = this.createGroup(group); - this.groups.set(groupObject.path.toString(), groupObject); + this.updateGroupMap(group.name, this.createGroup(group)); } for (const [root, children] of childMap) { @@ -268,9 +259,14 @@ export abstract class BaseRegistry { assert(rootGroup !== undefined, `Parent group '${root}' does not exist'`); for (const child of children) { + if (rootGroup.hasGroup(child.name)) { + warn(`Skipping duplicate child group in ${root}: ${child}`); + continue; + } + const childGroup = this.createGroup(child); rootGroup.addGroup(childGroup); - this.groups.set(childGroup.path.toString(), childGroup); + this.updateGroupMap(childGroup.path.toString(), childGroup); } } } @@ -298,4 +294,12 @@ export abstract class BaseRegistry { if (hasGroup) throw `Duplicate group: ${path}`; } + + protected updateCommandMap(key: string, command: BaseCommand) { + this.commands.set(key, command); + } + + protected updateGroupMap(key: string, group: CommandGroup) { + this.groups.set(key, group); + } }