import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { Button, Space, message } from 'antd'; import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { t, type I18nParams } from '../i18n'; import { useOptionalI18n } from '../i18n/provider'; import Editor, { type OnMount } from './MonacoEditor'; import { isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues, } from '../utils/appearance'; import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme'; interface RedisCommandEditorProps { connectionId: string; redisDB: number; } interface CommandResult { command: string; result: any; error?: string; timestamp: number; durationMs: number; } export const REDIS_COMMAND_EDITOR_MIN_HEIGHT = 120; export const REDIS_COMMAND_OUTPUT_MIN_HEIGHT = 240; export const REDIS_COMMAND_RESIZER_HEIGHT = 8; export const clampRedisCommandEditorHeight = ( requestedHeight: number, containerHeight: number | undefined, ): number => { const minHeight = REDIS_COMMAND_EDITOR_MIN_HEIGHT; const fallbackMaxHeight = 800; const maxHeight = containerHeight ? Math.max( minHeight, containerHeight - REDIS_COMMAND_OUTPUT_MIN_HEIGHT - REDIS_COMMAND_RESIZER_HEIGHT, ) : fallbackMaxHeight; return Math.min(Math.max(requestedHeight, minHeight), maxHeight); }; // 智能解析 Redis 脚本块,保护多行引号内的换行符 function parseRedisScriptBlocks(script: string): string[] { const blocks: string[] = []; let currentBlock = ""; let inQuote: string | null = null; let isEscaping = false; const lines = script.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); if (!inQuote && (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('#'))) { continue; } for (let j = 0; j < line.length; j++) { const char = line[j]; if (isEscaping) { isEscaping = false; currentBlock += char; continue; } if (char === '\\') { isEscaping = true; currentBlock += char; continue; } if (char === '"' || char === "'") { if (inQuote === char) { inQuote = null; } else if (!inQuote) { inQuote = char; } } currentBlock += char; } if (inQuote || (i < lines.length - 1 && currentBlock.trim() !== '')) { if (!inQuote) { blocks.push(currentBlock.trim()); currentBlock = ""; } else { currentBlock += '\n'; } } } if (currentBlock.trim() !== '') { blocks.push(currentBlock.trim()); } return blocks.filter(b => b.trim() !== ''); } const RedisCommandEditor: React.FC = ({ connectionId, redisDB }) => { const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); const appearance = useStore(state => state.appearance); const i18n = useOptionalI18n(); const i18nLanguage = i18n?.language; const tr = (key: string, params?: I18nParams) => t(key, params, i18nLanguage); const connection = connections.find(c => c.id === connectionId); const darkMode = theme === 'dark'; const isV2Ui = appearance.uiVersion === 'v2'; const resolvedAppearance = resolveAppearanceValues(appearance); const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); const blur = normalizeBlurForPlatform(resolvedAppearance.blur); const disableLocalBackdropFilter = isMacLikePlatform(); const workbenchTheme = useMemo( () => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }), [blur, darkMode, disableLocalBackdropFilter, opacity, appearance.uiVersion], ); const [command, setCommand] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); // UI Layout state const [editorHeight, setEditorHeight] = useState(250); const dragRef = useRef<{ startY: number; startHeight: number } | null>(null); const containerRef = useRef(null); const resultsEndRef = useRef(null); const editorRef = useRef(null); const getConfig = useCallback(() => { if (!connection) return null; return { ...connection.config, port: Number(connection.config.port), password: connection.config.password || "", useSSH: connection.config.useSSH || false, ssh: connection.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }, redisDB: redisDB }; }, [connection, redisDB]); const handleEditorMount: OnMount = (editor, monaco) => { editorRef.current = editor; editor.addCommand( monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => handleExecute() ); if (!(window as any).__redisCompletionRegistered) { (window as any).__redisCompletionRegistered = true; const redisCommands = [ "APPEND", "AUTH", "BGREWRITEAOF", "BGSAVE", "BITCOUNT", "BITFIELD", "BITOP", "BITPOS", "BLPOP", "BRPOP", "BRPOPLPUSH", "BZMPOP", "BZPOPMIN", "BZPOPMAX", "CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DBSIZE", "DEBUG", "DECR", "DECRBY", "DEL", "DISCARD", "DUMP", "ECHO", "EVAL", "EVALSHA", "EXEC", "EXISTS", "EXPIRE", "EXPIREAT", "EXPIRETIME", "FLUSHALL", "FLUSHDB", "GEOADD", "GEODIST", "GEOHASH", "GEOPOS", "GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH", "GEOSEARCHSTORE", "GET", "GETBIT", "GETDEL", "GETEX", "GETRANGE", "GETSET", "HDEL", "HELLO", "HEXISTS", "HGET", "HGETALL", "HINCRBY", "HINCRBYFLOAT", "HKEYS", "HLEN", "HMGET", "HMSET", "HSCAN", "HSET", "HSETNX", "HSTRLEN", "HVALS", "INCR", "INCRBY", "INCRBYFLOAT", "INFO", "KEYS", "LASTSAVE", "LCS", "LINDEX", "LINSERT", "LLEN", "LMOVE", "LMPOP", "LPOP", "LPOS", "LPUSH", "LPUSHX", "LRANGE", "LREM", "LSET", "LTRIM", "MEMORY", "MGET", "MIGRATE", "MODULE", "MONITOR", "MOVE", "MSET", "MSETNX", "MULTI", "OBJECT", "PERSIST", "PEXPIRE", "PEXPIREAT", "PEXPIRETIME", "PFADD", "PFCOUNT", "PFMERGE", "PING", "PSETEX", "PSUBSCRIBE", "PTTL", "PUBLISH", "PUBSUB", "PUNSUBSCRIBE", "QUIT", "RANDOMKEY", "READONLY", "READWRITE", "RENAME", "RENAMENX", "RESET", "RESTORE", "ROLE", "RPOP", "RPOPLPUSH", "RPUSH", "RPUSHX", "SADD", "SAVE", "SCAN", "SCARD", "SCRIPT", "SDIFF", "SDIFFSTORE", "SELECT", "SET", "SETBIT", "SETEX", "SETNX", "SETRANGE", "SHUTDOWN", "SINTER", "SINTERCARD", "SINTERSTORE", "SISMEMBER", "SLAVEOF", "SLOWLOG", "SMEMBERS", "SMISMEMBER", "SMOVE", "SORT", "SORT_RO", "SPOP", "SRANDMEMBER", "SREM", "SSCAN", "STRLEN", "SUBSCRIBE", "SUNION", "SUNIONSTORE", "SWAPDB", "SYNC", "TIME", "TOUCH", "TTL", "TYPE", "UNLINK", "UNSUBSCRIBE", "UNWATCH", "WAIT", "WATCH", "XACK", "XADD", "XAUTOCLAIM", "XCLAIM", "XDEL", "XGROUP", "XINFO", "XLEN", "XPENDING", "XRANGE", "XREAD", "XREADGROUP", "XREVRANGE", "XTRIM", "ZADD", "ZCARD", "ZCOUNT", "ZDIFF", "ZDIFFSTORE", "ZINCRBY", "ZINTER", "ZINTERCARD", "ZINTERSTORE", "ZLEXCOUNT", "ZMPOP", "ZMSCORE", "ZPOPMAX", "ZPOPMIN", "ZRANDMEMBER", "ZRANGE", "ZRANGEBYLEX", "ZRANGEBYSCORE", "ZRANK", "ZREM", "ZREMRANGEBYLEX", "ZREMRANGEBYRANK", "ZREMRANGEBYSCORE", "ZREVRANGE", "ZREVRANGEBYLEX", "ZREVRANGEBYSCORE", "ZREVRANK", "ZSCAN", "ZSCORE", "ZUNION", "ZUNIONSTORE" ]; monaco.languages.registerCompletionItemProvider('redis', { provideCompletionItems: (model: any, position: any) => { const word = model.getWordUntilPosition(position); const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn }; return { suggestions: redisCommands.map(cmd => ({ label: cmd, kind: monaco.languages.CompletionItemKind.Keyword, insertText: cmd, range: range, detail: tr('redis_command.completion.detail') })) }; } }); } }; const handleExecute = async () => { const config = getConfig(); if (!config) return; let cmdToExecute = ''; // 1. 获取用户是否有高亮选中的文本 const selection = editorRef.current?.getSelection(); if (selection && !selection.isEmpty()) { cmdToExecute = editorRef.current?.getModel()?.getValueInRange(selection) || ''; } else { // 没有选中则取全部文本 cmdToExecute = editorRef.current?.getValue() || ''; } cmdToExecute = cmdToExecute.trim(); if (!cmdToExecute) { message.warning(tr('redis_command.message.command_required')); return; } // 2. 智能解析多行命令 const commands = parseRedisScriptBlocks(cmdToExecute); if (commands.length === 0) return; setLoading(true); const newResults: CommandResult[] = []; for (const cmd of commands) { const start = Date.now(); try { const res = await (window as any).go.app.App.RedisExecuteCommand(buildRpcConnectionConfig(config), cmd); newResults.push({ command: cmd, result: res.success ? res.data : null, error: res.success ? undefined : res.message, timestamp: Date.now(), durationMs: Date.now() - start }); } catch (e: any) { newResults.push({ command: cmd, result: null, error: e?.message || String(e), timestamp: Date.now(), durationMs: Date.now() - start }); } } setResults(prev => [...prev, ...newResults]); setLoading(false); }; // Auto scroll to bottom when new results arrive useEffect(() => { if (resultsEndRef.current) { resultsEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [results]); const handleClear = () => { setResults([]); }; const formatResult = (result: any): React.ReactNode => { if (result === null || result === undefined) { return (nil); } if (typeof result === 'string') { // 尝试美化 JSON 字符串 try { const parsed = JSON.parse(result); if (typeof parsed === 'object' && parsed !== null) { return (
{JSON.stringify(parsed, null, 2)}
); } } catch (e) { // not a valid json, just return string } return "{result}"; } if (typeof result === 'number') { return (integer) {result}; } if (Array.isArray(result)) { if (result.length === 0) { return '(empty array)'; } return (
{result.map((item, index) => (
{index + 1})
{formatResult(item)}
))}
); } if (typeof result === 'object') { return JSON.stringify(result, null, 2); } return String(result); }; // Resizing logic const handleDragStart = (e: React.MouseEvent) => { e.preventDefault(); dragRef.current = { startY: e.clientY, startHeight: editorHeight }; document.addEventListener('mousemove', handleDragMove); document.addEventListener('mouseup', handleDragEnd); document.body.style.cursor = 'row-resize'; }; const handleDragMove = useCallback((e: MouseEvent) => { if (!dragRef.current) return; const delta = e.clientY - dragRef.current.startY; let newHeight = dragRef.current.startHeight + delta; // 限制输入区高度,避免拖拽后压缩掉底部输出区。 newHeight = clampRedisCommandEditorHeight( newHeight, containerRef.current?.clientHeight, ); setEditorHeight(newHeight); // 更新编辑器布局 if (editorRef.current) { editorRef.current.layout(); } }, []); const handleDragEnd = useCallback(() => { dragRef.current = null; document.removeEventListener('mousemove', handleDragMove); document.removeEventListener('mouseup', handleDragEnd); document.body.style.cursor = 'default'; if (editorRef.current) { editorRef.current.layout(); } }, [handleDragMove]); if (!connection) { return
{tr('redis_command.state.connection_not_found')}
; } return (
{/* Editor Top Pane */}
{tr('redis_command.title.console')} db{redisDB}
setCommand(value || '')} onMount={handleEditorMount} options={{ minimap: { enabled: false }, lineNumbers: 'on', wordWrap: 'on', scrollBeyondLastLine: false, automaticLayout: true, tabSize: 4, padding: { top: 10, bottom: 10 } }} />
{/* Resizer Handle */}
{/* Results Terminal Bottom Pane */}
{tr('redis_command.output.title')}
{results.length === 0 ? (
{tr('redis_command.output.empty_hint')}
{tr('redis_command.output.selection_tip')}
) : ( results.map((item, index) => (
{item.command} [{item.durationMs}ms]
{item.error ? (
(error) {item.error}
) : (
{formatResult(item.result)}
)}
)) )}
); }; export default RedisCommandEditor;