import { useEffect, useMemo, useRef, useState } from 'react';
import {
AlertTriangle,
Check,
LoaderCircle,
Plus,
RefreshCcw,
Search,
Server,
Terminal,
Workflow,
X,
} from 'lucide-react';
import type { PolicyCommandCatalogEntry, PolicyCommandSource } from '../types';
import { ModalPortal } from './modal-portal';
import { Button, Input, Select } from './ui';
interface CommandCatalogPickerProps {
commands: PolicyCommandCatalogEntry[];
selectedCommandIds: string[];
loading: boolean;
error?: string;
onAdd: (commandId: string) => void;
onReload: () => void;
}
type SourceFilter = 'all' | PolicyCommandSource;
function commandRoot(root: string): string {
const normalized = root.trim().replace(/^\/+/, '');
return normalized ? `/${normalized}` : '/';
}
function commandReferenceForms(reference: string): string[] {
const token = reference.trim().split(/\s+/, 1)[0]?.replace(/^\/+/, '').toLocaleLowerCase() ?? '';
if (!token) return [];
return token.includes(':') ? [token] : [token, `minecraft:${token}`];
}
/** Treat hand-written `/root`, `root`, aliases and canonical IDs as the same command. */
export function commandIsSelected(command: PolicyCommandCatalogEntry, selectedCommandIds: string[]): boolean {
const catalogForms = new Set([
...commandReferenceForms(command.id),
...commandReferenceForms(command.root),
...(command.aliases ?? []).flatMap(commandReferenceForms),
]);
return selectedCommandIds.some((selected) => commandReferenceForms(selected).some((form) => catalogForms.has(form)));
}
function searchableText(command: PolicyCommandCatalogEntry): string {
return [
command.id,
command.root,
...command.usages,
...(command.aliases ?? []),
...(command.arguments ?? []),
command.triggerId ?? '',
command.triggerName ?? '',
command.source === 'trigger' ? '触发器 trigger 自定义指令' : '服务器 server 原版 模组',
].join(' ').toLocaleLowerCase();
}
export function CommandCatalogPicker({
commands,
selectedCommandIds,
loading,
error,
onAdd,
onReload,
}: CommandCatalogPickerProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [source, setSource] = useState<SourceFilter>('all');
const launcherRef = useRef<HTMLButtonElement>(null);
const filteredCommands = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
return commands.filter((command) => (
(source === 'all' || command.source === source)
&& (!normalizedQuery || searchableText(command).includes(normalizedQuery))
));
}, [commands, query, source]);
const visibleCommands = filteredCommands.slice(0, 200);
const close = () => {
setOpen(false);
globalThis.requestAnimationFrame?.(() => launcherRef.current?.focus());
};
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
close();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open]);
return <>
<button ref={launcherRef} type="button" className="button button--secondary command-catalog-launcher"
aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen(true)}>
<Search size={15} />添加命令 / Add command
</button>
{open && <ModalPortal>
<div className="command-catalog-backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget) close();
}}>
<section className="command-catalog" role="dialog" aria-modal="true"
aria-labelledby="command-catalog-title">
<header className="command-catalog__header">
<div>
<span id="command-catalog-title"><Terminal size={17} />命令目录 / Command catalog</span>
<small>搜索服务器和触发器实时注册的命令,添加后将写入当前规则的规范命令 ID。</small>
</div>
<div className="command-catalog__header-actions">
<Button type="button" variant="ghost" onClick={onReload} disabled={loading}>
<RefreshCcw className={loading ? 'spin' : ''} size={15} />刷新
</Button>
<button type="button" aria-label="关闭命令目录 / Close command catalog" onClick={close}><X size={18} /></button>
</div>
</header>
<div className="command-catalog__filters">
<label className="command-catalog__search"><Search size={15} /><Input type="search" autoFocus
aria-label="搜索命令 / Search commands" value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索命令 ID、/指令、用法、别名或参数" /></label>
<Select aria-label="命令来源 / Command source" value={source}
onChange={(event) => setSource(event.target.value as SourceFilter)}>
<option value="all">全部命令 / All commands</option>
<option value="server">服务器命令 / Server</option>
<option value="trigger">触发器指令 / Trigger</option>
</Select>
</div>
<div className="command-catalog__summary" aria-live="polite">
显示 {filteredCommands.length} / {commands.length} 个命令
{filteredCommands.length > visibleCommands.length
&& <span>· 当前列出前 {visibleCommands.length} 项,请继续输入关键词缩小范围</span>}
{loading && commands.length > 0 && <span><LoaderCircle className="spin" size={12} />正在刷新…</span>}
</div>
<div className="command-catalog__results">
{loading && commands.length === 0 && <div className="command-catalog__state" role="status">
<LoaderCircle className="spin" size={23} /><strong>正在加载命令目录…</strong>
<span>正在读取服务器命令与触发器动态指令。</span>
</div>}
{!loading && error && commands.length === 0 && <div className="command-catalog__state command-catalog__state--error" role="alert">
<AlertTriangle size={23} /><strong>命令目录加载失败</strong><span>{error}</span>
<Button type="button" variant="secondary" onClick={onReload}><RefreshCcw size={14} />重新加载</Button>
</div>}
{error && commands.length > 0 && <div className="command-catalog__stale-warning" role="status">
<AlertTriangle size={14} />刷新失败,当前显示上次成功加载的目录:{error}
</div>}
{!loading && !error && commands.length === 0 && <div className="command-catalog__state">
<Terminal size={23} /><strong>服务器尚未提供可用命令</strong>
<span>创建并启用指令触发器后,它也会出现在这里。</span>
<Button type="button" variant="secondary" onClick={onReload}><RefreshCcw size={14} />刷新目录</Button>
</div>}
{commands.length > 0 && filteredCommands.length === 0 && <div className="command-catalog__state">
<Search size={23} /><strong>没有匹配的命令</strong>
<span>请尝试命令 ID、根命令、用法、别名、参数名称或切换来源。</span>
</div>}
{visibleCommands.map((command) => {
const selected = commandIsSelected(command, selectedCommandIds);
const SourceIcon = command.source === 'trigger' ? Workflow : Server;
const usages = command.usages.length > 0 ? command.usages : [commandRoot(command.root)];
return <article className={`command-catalog__item${selected ? ' is-selected' : ''}`} key={command.id}>
<div className="command-catalog__item-heading">
<span className="command-catalog__root"><Terminal size={14} /><strong>{commandRoot(command.root)}</strong></span>
<span className={`command-catalog__source command-catalog__source--${command.source}`}>
<SourceIcon size={12} />{command.source === 'trigger' ? '触发器指令' : '服务器命令'}
</span>
</div>
<div className="command-catalog__canonical"><span>规范 ID / Canonical ID</span><code>{command.id}</code></div>
{command.source === 'trigger' && (command.triggerName || command.triggerId) && <p className="command-catalog__trigger">
触发器:<strong>{command.triggerName ?? command.triggerId}</strong>
{command.triggerName && command.triggerId && <code>{command.triggerId}</code>}
</p>}
{(command.arguments?.length ?? 0) > 0 && <div className="command-catalog__arguments">
<span>已声明参数:</span>{command.arguments?.map((argument) => <code key={argument}>{argument}</code>)}
</div>}
{(command.aliases?.length ?? 0) > 0 && <div className="command-catalog__aliases">
<span>别名:</span>{command.aliases?.map((alias) => <code key={alias}>{commandRoot(alias)}</code>)}
</div>}
<div className="command-catalog__usages">
<span>用法 / Usage</span>
{usages.slice(0, 4).map((usage, index) => (
<code key={`${index}:${usage}`}>{usage}</code>
))}
{usages.length > 4 && <small>另有 {usages.length - 4} 种用法,可通过搜索匹配。</small>}
</div>
<Button type="button" variant={selected ? 'ghost' : 'secondary'} disabled={selected}
aria-label={selected ? `命令 ${command.id} 已添加` : `添加命令 ${command.id}`}
onClick={() => onAdd(command.id)}>
{selected ? <Check size={14} /> : <Plus size={14} />}{selected ? '已添加' : '添加到规则'}
</Button>
</article>;
})}
</div>
</section>
</div>
</ModalPortal>}
</>;
}
import { useEffect, useMemo, useRef, useState } from 'react';
import {
AlertTriangle,
Check,
LoaderCircle,
Plus,
RefreshCcw,
Search,
Server,
Terminal,
Workflow,
X,
} from 'lucide-react';
import type { PolicyCommandCatalogEntry, PolicyCommandSource } from '../types';
import { ModalPortal } from './modal-portal';
import { Button, Input, Select } from './ui';
interface CommandCatalogPickerProps {
commands: PolicyCommandCatalogEntry[];
selectedCommandIds: string[];
loading: boolean;
error?: string;
onAdd: (commandId: string) => void;
onReload: () => void;
}
type SourceFilter = 'all' | PolicyCommandSource;
function commandRoot(root: string): string {
const normalized = root.trim().replace(/^\/+/, '');
return normalized ? `/${normalized}` : '/';
}
function commandReferenceForms(reference: string): string[] {
const token = reference.trim().split(/\s+/, 1)[0]?.replace(/^\/+/, '').toLocaleLowerCase() ?? '';
if (!token) return [];
return token.includes(':') ? [token] : [token, `minecraft:${token}`];
}
/** Treat hand-written `/root`, `root`, aliases and canonical IDs as the same command. */
export function commandIsSelected(command: PolicyCommandCatalogEntry, selectedCommandIds: string[]): boolean {
const catalogForms = new Set([
...commandReferenceForms(command.id),
...commandReferenceForms(command.root),
...(command.aliases ?? []).flatMap(commandReferenceForms),
]);
return selectedCommandIds.some((selected) => commandReferenceForms(selected).some((form) => catalogForms.has(form)));
}
function searchableText(command: PolicyCommandCatalogEntry): string {
return [
command.id,
command.root,
...command.usages,
...(command.aliases ?? []),
...(command.arguments ?? []),
command.triggerId ?? '',
command.triggerName ?? '',
command.source === 'trigger' ? '触发器 trigger 自定义指令' : '服务器 server 原版 模组',
].join(' ').toLocaleLowerCase();
}
export function CommandCatalogPicker({
commands,
selectedCommandIds,
loading,
error,
onAdd,
onReload,
}: CommandCatalogPickerProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [source, setSource] = useState<SourceFilter>('all');
const launcherRef = useRef<HTMLButtonElement>(null);
const filteredCommands = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
return commands.filter((command) => (
(source === 'all' || command.source === source)
&& (!normalizedQuery || searchableText(command).includes(normalizedQuery))
));
}, [commands, query, source]);
const visibleCommands = filteredCommands.slice(0, 200);
const close = () => {
setOpen(false);
globalThis.requestAnimationFrame?.(() => launcherRef.current?.focus());
};
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
close();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open]);
return <>
<button ref={launcherRef} type="button" className="button button--secondary command-catalog-launcher"
aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen(true)}>
<Search size={15} />添加命令 / Add command
</button>
{open && <ModalPortal>
<div className="command-catalog-backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget) close();
}}>
<section className="command-catalog" role="dialog" aria-modal="true"
aria-labelledby="command-catalog-title">
<header className="command-catalog__header">
<div>
<span id="command-catalog-title"><Terminal size={17} />命令目录 / Command catalog</span>
<small>搜索服务器和触发器实时注册的命令,添加后将写入当前规则的规范命令 ID。</small>
</div>
<div className="command-catalog__header-actions">
<Button type="button" variant="ghost" onClick={onReload} disabled={loading}>
<RefreshCcw className={loading ? 'spin' : ''} size={15} />刷新
</Button>
<button type="button" aria-label="关闭命令目录 / Close command catalog" onClick={close}><X size={18} /></button>
</div>
</header>
<div className="command-catalog__filters">
<label className="command-catalog__search"><Search size={15} /><Input type="search" autoFocus
aria-label="搜索命令 / Search commands" value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索命令 ID、/指令、用法、别名或参数" /></label>
<Select aria-label="命令来源 / Command source" value={source}
onChange={(event) => setSource(event.target.value as SourceFilter)}>
<option value="all">全部命令 / All commands</option>
<option value="server">服务器命令 / Server</option>
<option value="trigger">触发器指令 / Trigger</option>
</Select>
</div>
<div className="command-catalog__summary" aria-live="polite">
显示 {filteredCommands.length} / {commands.length} 个命令
{filteredCommands.length > visibleCommands.length
&& <span>· 当前列出前 {visibleCommands.length} 项,请继续输入关键词缩小范围</span>}
{loading && commands.length > 0 && <span><LoaderCircle className="spin" size={12} />正在刷新…</span>}
</div>
<div className="command-catalog__results">
{loading && commands.length === 0 && <div className="command-catalog__state" role="status">
<LoaderCircle className="spin" size={23} /><strong>正在加载命令目录…</strong>
<span>正在读取服务器命令与触发器动态指令。</span>
</div>}
{!loading && error && commands.length === 0 && <div className="command-catalog__state command-catalog__state--error" role="alert">
<AlertTriangle size={23} /><strong>命令目录加载失败</strong><span>{error}</span>
<Button type="button" variant="secondary" onClick={onReload}><RefreshCcw size={14} />重新加载</Button>
</div>}
{error && commands.length > 0 && <div className="command-catalog__stale-warning" role="status">
<AlertTriangle size={14} />刷新失败,当前显示上次成功加载的目录:{error}
</div>}
{!loading && !error && commands.length === 0 && <div className="command-catalog__state">
<Terminal size={23} /><strong>服务器尚未提供可用命令</strong>
<span>创建并启用指令触发器后,它也会出现在这里。</span>
<Button type="button" variant="secondary" onClick={onReload}><RefreshCcw size={14} />刷新目录</Button>
</div>}
{commands.length > 0 && filteredCommands.length === 0 && <div className="command-catalog__state">
<Search size={23} /><strong>没有匹配的命令</strong>
<span>请尝试命令 ID、根命令、用法、别名、参数名称或切换来源。</span>
</div>}
{visibleCommands.map((command) => {
const selected = commandIsSelected(command, selectedCommandIds);
const SourceIcon = command.source === 'trigger' ? Workflow : Server;
const usages = command.usages.length > 0 ? command.usages : [commandRoot(command.root)];
return <article className={`command-catalog__item${selected ? ' is-selected' : ''}`} key={command.id}>
<div className="command-catalog__item-heading">
<span className="command-catalog__root"><Terminal size={14} /><strong>{commandRoot(command.root)}</strong></span>
<span className={`command-catalog__source command-catalog__source--${command.source}`}>
<SourceIcon size={12} />{command.source === 'trigger' ? '触发器指令' : '服务器命令'}
</span>
</div>
<div className="command-catalog__canonical"><span>规范 ID / Canonical ID</span><code>{command.id}</code></div>
{command.source === 'trigger' && (command.triggerName || command.triggerId) && <p className="command-catalog__trigger">
触发器:<strong>{command.triggerName ?? command.triggerId}</strong>
{command.triggerName && command.triggerId && <code>{command.triggerId}</code>}
</p>}
{(command.arguments?.length ?? 0) > 0 && <div className="command-catalog__arguments">
<span>已声明参数:</span>{command.arguments?.map((argument) => <code key={argument}>{argument}</code>)}
</div>}
{(command.aliases?.length ?? 0) > 0 && <div className="command-catalog__aliases">
<span>别名:</span>{command.aliases?.map((alias) => <code key={alias}>{commandRoot(alias)}</code>)}
</div>}
<div className="command-catalog__usages">
<span>用法 / Usage</span>
{usages.slice(0, 4).map((usage, index) => (
<code key={`${index}:${usage}`}>{usage}</code>
))}
{usages.length > 4 && <small>另有 {usages.length - 4} 种用法,可通过搜索匹配。</small>}
</div>
<Button type="button" variant={selected ? 'ghost' : 'secondary'} disabled={selected}
aria-label={selected ? `命令 ${command.id} 已添加` : `添加命令 ${command.id}`}
onClick={() => onAdd(command.id)}>
{selected ? <Check size={14} /> : <Plus size={14} />}{selected ? '已添加' : '添加到规则'}
</Button>
</article>;
})}
</div>
</section>
</div>
</ModalPortal>}
</>;
}