import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { ConfigProvider, Tag, Tooltip, message } from 'antd'; import { ApiOutlined, ApartmentOutlined, CheckCircleFilled, ClockCircleOutlined, CloseCircleFilled, CloudServerOutlined, ClusterOutlined, CodeOutlined, CopyOutlined, DashboardOutlined, DatabaseOutlined, DiscordOutlined, ExportOutlined, FileTextOutlined, GlobalOutlined, ImportOutlined, LoadingOutlined, MailOutlined, MessageOutlined, MoonOutlined, PlusOutlined, ReloadOutlined, SafetyOutlined, SearchOutlined, SettingOutlined, SunOutlined, SwapOutlined, TagsOutlined, TeamOutlined, ToolOutlined, } from '@ant-design/icons'; import { ClipboardManager, HttpUtil, SizeFormatter } from '@/utils'; import { activateOnKey } from '@/utils/a11y'; import { useInboundOptions } from '@/api/queries/useInboundOptions'; import { useAllSettings } from '@/api/queries/useAllSettings'; import { useTheme } from '@/hooks/useTheme'; import type { ClientRecord, InboundOption } from '@/schemas/client'; import { commandPaletteStore, useCommandPalette } from './useCommandPalette'; import './CommandPalette.css'; interface PaletteItem { id: string; category: 'clients' | 'inbounds' | 'navigation' | 'settings' | 'actions'; title: string; subtitle?: string; keywords?: string[]; icon: ReactNode; tag?: ReactNode; action: () => void | Promise; secondaryAction?: { label: string; icon: ReactNode; execute: (e: React.MouseEvent) => void; }; } export default function CommandPalette() { const { t } = useTranslation(); const navigate = useNavigate(); const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme(); const { isOpen, close } = useCommandPalette(); const { allSetting } = useAllSettings(); const { data: inbounds = [] } = useInboundOptions(); const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [clientSearch, setClientSearch] = useState<{ query: string; items: ClientRecord[] }>({ query: '', items: [], }); const [loadingClients, setLoadingClients] = useState(false); const [activeIndex, setActiveIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); useEffect(() => { function handleGlobalKeyDown(e: KeyboardEvent) { const isK = e.code === 'KeyK' || e.key === 'k' || e.key === 'K'; if ((e.metaKey || e.ctrlKey) && isK) { e.preventDefault(); if (isOpen) { close(); } else { commandPaletteStore.open(); } } else if (e.key === 'Escape' && isOpen) { e.preventDefault(); close(); } } window.addEventListener('keydown', handleGlobalKeyDown, { capture: true }); return () => { window.removeEventListener('keydown', handleGlobalKeyDown, { capture: true }); }; }, [isOpen, close]); const [prevIsOpen, setPrevIsOpen] = useState(isOpen); if (isOpen !== prevIsOpen) { setPrevIsOpen(isOpen); if (!isOpen) { setQuery(''); setDebouncedQuery(''); setClientSearch({ query: '', items: [] }); setActiveIndex(0); setLoadingClients(false); } } const [prevQuery, setPrevQuery] = useState(query); if (query !== prevQuery) { setPrevQuery(query); setActiveIndex(0); if (!query.trim()) { setDebouncedQuery(''); setClientSearch({ query: '', items: [] }); setLoadingClients(false); } } useEffect(() => { if (isOpen) { setTimeout(() => inputRef.current?.focus(), 50); } }, [isOpen]); useEffect(() => { if (!isOpen) { return; } const trimmed = query.trim(); if (!trimmed || trimmed === debouncedQuery) { return; } const timer = window.setTimeout(() => { setLoadingClients(true); setDebouncedQuery(trimmed); }, 300); return () => { window.clearTimeout(timer); }; }, [isOpen, query, debouncedQuery]); useEffect(() => { if (!isOpen || debouncedQuery.length < 1) { return; } let isCurrent = true; const controller = new AbortController(); HttpUtil.get( `/panel/api/clients/list/paged?search=${encodeURIComponent(debouncedQuery)}&pageSize=8`, undefined, { silent: true, signal: controller.signal }, ) .then((msg) => { if (!isCurrent) return; if ( msg?.success && msg?.obj && Array.isArray((msg.obj as { items?: ClientRecord[] }).items) ) { setClientSearch({ query: debouncedQuery, items: (msg.obj as { items: ClientRecord[] }).items, }); } else { setClientSearch({ query: debouncedQuery, items: [] }); } }) .finally(() => { if (isCurrent) setLoadingClients(false); }); return () => { isCurrent = false; controller.abort(); }; }, [isOpen, debouncedQuery]); const copySubscription = useCallback( async (client: ClientRecord) => { if (!client.subId || !allSetting.subURI) { message.warning(t('pages.clients.noSubId')); return; } const link = `${allSetting.subURI}${client.subId}`; const ok = await ClipboardManager.copyText(link); if (ok) message.success(t('copied')); }, [allSetting.subURI, t], ); const restartXray = useCallback(async () => { close(); const msg = await HttpUtil.post('/panel/api/server/restartXrayService', undefined, { silentSuccess: true, }); if (msg?.success) { message.success(t('commandPalette.restartXraySuccess')); } }, [close, t]); const cycleTheme = useCallback(() => { if (!isDark) { toggleTheme(); if (isUltra) toggleUltra(); } else if (!isUltra) { toggleUltra(); } else { toggleUltra(); toggleTheme(); } close(); }, [isDark, isUltra, toggleTheme, toggleUltra, close]); const trimmedQuery = query.trim(); const isDebouncing = isOpen && trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery; const isClientSearching = isOpen && trimmedQuery.length > 0 && (loadingClients || isDebouncing || clientSearch.query !== trimmedQuery); const items = useMemo(() => { const list: PaletteItem[] = []; const q = query.trim().toLowerCase(); const matches = (title: string, subtitle?: string, keywords: string[] = []) => { if (!q) return true; if (title.toLowerCase().includes(q)) return true; if (subtitle && subtitle.toLowerCase().includes(q)) return true; return keywords.some((k) => k.toLowerCase().includes(q)); }; const trimmed = query.trim(); if (trimmed.length > 0 && clientSearch.query === trimmed && clientSearch.items.length > 0) { clientSearch.items.forEach((c) => { const up = Number(c.traffic?.up || 0); const down = Number(c.traffic?.down || 0); const total = Number(c.traffic?.total || c.totalGB || 0); const trafficUsed = SizeFormatter.sizeFormat(up + down); const trafficTotal = total > 0 ? SizeFormatter.sizeFormat(total) : '∞'; const isOnline = c.enable !== false; list.push({ id: `client-${c.id ?? c.email}`, category: 'clients', title: c.email, subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`, icon: isOnline ? ( ) : ( ), action: () => { close(); navigate(`/clients?search=${encodeURIComponent(c.email)}`); }, secondaryAction: c.subId && allSetting.subURI ? { label: t('commandPalette.copySubscription'), icon: , execute: (e) => { e.stopPropagation(); copySubscription(c); }, } : undefined, }); }); } const matchedInbounds = inbounds.filter((ib: InboundOption) => { if (!q) return false; return ( (ib.tag && ib.tag.toLowerCase().includes(q)) || (ib.remark && ib.remark.toLowerCase().includes(q)) || (ib.protocol && ib.protocol.toLowerCase().includes(q)) || (ib.port && String(ib.port).includes(q)) ); }); matchedInbounds.slice(0, 8).forEach((ib) => { const tags: ReactNode[] = []; if (ib.protocol) { tags.push( {ib.protocol} , ); } if (ib.network) { const n = ib.network.toLowerCase(); let netLabel = n.toUpperCase(); if (n === 'httpupgrade') netLabel = 'HTTPUpgrade'; else if (n === 'splithttp') netLabel = 'SplitHTTP'; else if (n === 'xhttp') netLabel = 'XHTTP'; tags.push( {netLabel} , ); } if (ib.security && ib.security !== 'none') { const s = ib.security.toLowerCase(); const secLabel = s === 'reality' ? 'Reality' : s === 'tls' ? 'TLS' : s.toUpperCase(); tags.push( {secLabel} , ); } list.push({ id: `inbound-${ib.id}`, category: 'inbounds', title: ib.remark || ib.tag || `Inbound #${ib.id}`, subtitle: `Port ${ib.port || ''}`, icon: , tag: tags.length > 0 ? (
{tags}
) : undefined, action: () => { close(); navigate(`/inbounds?search=${encodeURIComponent(ib.remark || String(ib.port || ''))}`); }, }); }); const pages = [ { path: '/', title: t('menu.dashboard'), keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'], icon: , }, { path: '/inbounds', title: t('menu.inbounds'), keywords: [ 'inbounds', 'ports', 'vless', 'vmess', 'reality', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', ], icon: , }, { path: '/clients', title: t('menu.clients'), keywords: ['clients', 'users', 'sub', 'traffic', 'quota'], icon: , }, { path: '/groups', title: t('menu.groups'), keywords: ['groups', 'tags', 'batch'], icon: , }, { path: '/nodes', title: t('menu.nodes'), keywords: ['nodes', 'servers', 'cluster', 'remote nodes'], icon: , }, { path: '/hosts', title: t('menu.hosts'), keywords: ['hosts', 'sni', 'domains'], icon: , }, { path: '/outbound', title: t('menu.outbounds'), keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'], icon: , }, { path: '/routing', title: t('menu.routing'), keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'], icon: , }, { path: '/settings', title: t('menu.settings'), keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'], icon: , }, { path: '/xray', title: t('menu.xray'), keywords: ['xray', 'templates', 'balancer', 'dns'], icon: , }, { path: '/api-docs', title: t('menu.apiDocs'), keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'], icon: , }, ]; pages .filter((p) => matches(p.title, undefined, p.keywords)) .forEach((p) => { list.push({ id: `nav-${p.path}`, category: 'navigation', title: p.title, keywords: p.keywords, icon: p.icon, action: () => { close(); navigate(p.path); }, }); }); const settingsSubSections = [ { path: '/settings#general', title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`, subtitle: t('pages.settings.panelSettings'), keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'], icon: , }, { path: '/settings#security', title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`, subtitle: t('pages.settings.securitySettings'), keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'], icon: , }, { path: '/settings#telegram', title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`, subtitle: t('pages.settings.TGBotSettings'), keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'], icon: , }, { path: '/settings#email', title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`, subtitle: t('pages.settings.emailSettings'), keywords: ['email', 'smtp', 'mail', 'crash alerts'], icon: , }, { path: '/settings#discord', title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`, subtitle: t('pages.settings.discordSettings'), keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'], icon: , }, { path: '/settings#subscription', title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`, subtitle: t('pages.settings.subSettings'), keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'], icon: , }, { path: '/settings#subscription-formats', title: `${t('menu.settings')} · ${t('menu.subFormats')}`, subtitle: t('menu.subFormats'), keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'], icon: , }, { path: '/settings#subscription-balancers', title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`, subtitle: t('pages.settings.subBalancers.menu'), keywords: ['balancers', 'sub balancers', 'balancer nodes'], icon: , }, { path: '/xray#basic', title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`, subtitle: t('pages.xray.basicTemplate'), keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'], icon: , }, { path: '/xray#basic', title: `${t('menu.xray')} · ${t('pages.xray.metricsListen')}`, subtitle: t('pages.xray.metricsListen'), keywords: [ 'metrics', 'prometheus', 'statistics', 'listen', 'statsInbound', 'statsOutbound', 'metrics_out', ], icon: , }, { path: '/xray#basic', title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`, subtitle: t('pages.xray.connectionLimits'), keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'], icon: , }, { path: '/xray#basic', title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`, subtitle: t('pages.xray.logConfigs'), keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'], icon: , }, { path: '/xray#balancer', title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`, subtitle: t('pages.xray.Balancers'), keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'], icon: , }, { path: '/xray#dns', title: `${t('menu.xray')} · DNS`, subtitle: 'DNS', keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'], icon: , }, { path: '/xray#outbound', title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`, subtitle: t('pages.xray.Outbounds'), keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'], icon: , }, { path: '/xray#routing', title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`, subtitle: t('pages.xray.basicRouting'), keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'], icon: , }, { path: '/xray#advanced', title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`, subtitle: t('pages.xray.advancedTemplate'), keywords: ['advanced', 'json template', 'advanced config', 'custom json'], icon: , }, ]; settingsSubSections .filter((s) => matches(s.title, s.subtitle, s.keywords)) .forEach((s) => { list.push({ id: `setting-${s.path}-${s.title}`, category: 'settings', title: s.title, subtitle: s.subtitle, keywords: s.keywords, icon: s.icon, action: () => { close(); navigate(s.path); }, }); }); const actions: PaletteItem[] = [ { id: 'act-restart-xray', category: 'actions', title: t('commandPalette.restartXray'), subtitle: t('pages.index.restartXray'), keywords: ['restart', 'xray restart', 'reboot xray'], icon: , action: restartXray, }, { id: 'act-cycle-theme', category: 'actions', title: t('menu.theme'), subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light', keywords: ['theme', 'light', 'dark', 'ultra'], icon: isDark ? : , action: cycleTheme, }, { id: 'act-add-inbound', category: 'actions', title: t('pages.inbounds.addInbound'), subtitle: t('menu.inbounds'), keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'], icon: , action: () => { close(); navigate('/inbounds'); }, }, { id: 'act-add-client', category: 'actions', title: t('pages.clients.addClient'), subtitle: t('menu.clients'), keywords: ['add client', 'create user', 'new client', 'new user'], icon: , action: () => { close(); navigate('/clients'); }, }, ]; actions.filter((a) => matches(a.title, a.subtitle, a.keywords)).forEach((a) => list.push(a)); return list; }, [ query, clientSearch, inbounds, isDark, isUltra, allSetting.subURI, t, close, navigate, copySubscription, restartXray, cycleTheme, ]); const clampedActiveIndex = Math.min(activeIndex, Math.max(0, items.length - 1)); useEffect(() => { if (!listRef.current) return; const activeEl = listRef.current.querySelector( `.command-palette-item[data-index="${clampedActiveIndex}"]`, ) as HTMLElement | null; if (activeEl) { activeEl.scrollIntoView({ block: 'nearest' }); } }, [clampedActiveIndex]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex((prev) => (items.length ? (prev + 1) % items.length : 0)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex((prev) => (items.length ? (prev - 1 + items.length) % items.length : 0)); } else if (e.key === 'Enter') { e.preventDefault(); const current = items[clampedActiveIndex]; if (current) current.action(); } }; if (!isOpen) return null; let lastCategory = ''; const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light'; return (
{ if (e.target === e.currentTarget) close(); }} >
{isClientSearching ? ( ) : ( )} { setQuery(e.target.value); }} onKeyDown={handleKeyDown} />
{!isClientSearching && items.length === 0 && (
{t('noData')}
)} {items.map((item, index) => { const isFirstOfCategory = item.category !== lastCategory; lastCategory = item.category; const categoryLabel = item.category === 'clients' ? t('menu.clients') : item.category === 'inbounds' ? t('menu.inbounds') : item.category === 'navigation' ? t('commandPalette.navigation') : item.category === 'settings' ? t('commandPalette.settings') || t('menu.settings') : t('commandPalette.actions'); return (
{isFirstOfCategory && (
{categoryLabel}
)}
item.action()} onKeyDown={(e) => { // Enter on the nested copy button must activate that // button, not the row it sits in. if (e.target === e.currentTarget) activateOnKey(() => item.action())(e); }} onMouseEnter={() => setActiveIndex(index)} >
{item.icon}
{item.title} {item.subtitle && ( {item.subtitle} )}
{item.tag} {item.secondaryAction && ( )}
); })}
{t('commandPalette.navigate')} {t('commandPalette.select')} Esc {t('close')}
3x-ui Command Palette
); }