diff --git a/frontend/src/components/RedisCommandEditor.layout.test.tsx b/frontend/src/components/RedisCommandEditor.layout.test.tsx new file mode 100644 index 0000000..d3313b6 --- /dev/null +++ b/frontend/src/components/RedisCommandEditor.layout.test.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import RedisCommandEditor, { + REDIS_COMMAND_EDITOR_MIN_HEIGHT, + REDIS_COMMAND_OUTPUT_MIN_HEIGHT, + REDIS_COMMAND_RESIZER_HEIGHT, + clampRedisCommandEditorHeight, +} from './RedisCommandEditor'; + +const storeState = vi.hoisted((): any => ({ + connections: [ + { + id: 'redis-1', + name: 'redis', + config: { + type: 'redis', + host: '127.0.0.1', + port: 6379, + password: '', + database: '', + }, + }, + ], + theme: 'dark', + appearance: { + enabled: true, + opacity: 1, + blur: 0, + uiVersion: 'v2', + }, +})); + +vi.mock('../store', () => ({ + useStore: (selector?: (state: typeof storeState) => any) => ( + selector ? selector(storeState) : storeState + ), +})); + +vi.mock('@monaco-editor/react', async () => { + const React = await import('react'); + return { + loader: { config: vi.fn() }, + default: ({ theme, language }: any) => React.createElement( + 'div', + { + 'data-monaco-editor': 'true', + 'data-monaco-theme': theme, + 'data-language': language, + }, + ), + }; +}); + +vi.mock('@ant-design/icons', async () => { + const React = await import('react'); + const Icon = () => React.createElement('span', { 'data-icon': 'true' }); + return { + ClearOutlined: Icon, + PlayCircleOutlined: Icon, + }; +}); + +vi.mock('antd', async () => { + const React = await import('react'); + const Button = ({ children, icon, loading, size, type, ...props }: any) => React.createElement( + 'button', + props, + icon, + children, + ); + const Space = ({ children }: any) => React.createElement('div', { 'data-space': 'true' }, children); + return { + Button, + Space, + message: { + warning: vi.fn(), + }, + }; +}); + +describe('RedisCommandEditor layout', () => { + beforeEach(() => { + storeState.theme = 'dark'; + storeState.appearance = { + enabled: true, + opacity: 1, + blur: 0, + uiVersion: 'v2', + }; + }); + + it('renders command input and output panes with dark theme surfaces', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-redis-command-editor="true"'); + expect(markup).toContain('data-redis-command-input-pane="true"'); + expect(markup).toContain('data-redis-command-output-pane="true"'); + expect(markup).toContain('data-redis-command-output-terminal="true"'); + expect(markup).toContain('data-monaco-theme="transparent-dark"'); + expect(markup).toContain('background:#111418'); + expect(markup).not.toContain('background:#fff'); + expect(markup).not.toContain('background:#fdfdfd'); + }); + + it('uses the light transparent Monaco theme outside dark mode', () => { + storeState.theme = 'light'; + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-monaco-theme="transparent-light"'); + expect(markup).toContain('color:#0f172a'); + }); + + it('keeps output visible when the command editor is resized', () => { + const containerHeight = 900; + const maxEditorHeight = containerHeight + - REDIS_COMMAND_OUTPUT_MIN_HEIGHT + - REDIS_COMMAND_RESIZER_HEIGHT; + + expect(clampRedisCommandEditorHeight(60, containerHeight)).toBe(REDIS_COMMAND_EDITOR_MIN_HEIGHT); + expect(clampRedisCommandEditorHeight(700, containerHeight)).toBe(maxEditorHeight); + expect(clampRedisCommandEditorHeight(360, containerHeight)).toBe(360); + expect(clampRedisCommandEditorHeight(900, undefined)).toBe(800); + }); +}); diff --git a/frontend/src/components/RedisCommandEditor.tsx b/frontend/src/components/RedisCommandEditor.tsx index 3a0996b..0590557 100644 --- a/frontend/src/components/RedisCommandEditor.tsx +++ b/frontend/src/components/RedisCommandEditor.tsx @@ -1,9 +1,16 @@ -import React, { useState, useCallback, useRef, useEffect } from 'react'; +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 Editor, { type OnMount } from './MonacoEditor'; +import { + isMacLikePlatform, + normalizeBlurForPlatform, + normalizeOpacityForPlatform, + resolveAppearanceValues, +} from '../utils/appearance'; +import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme'; interface RedisCommandEditorProps { connectionId: string; @@ -18,6 +25,26 @@ interface CommandResult { 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[] = []; @@ -79,8 +106,19 @@ function parseRedisScriptBlocks(script: string): string[] { } const RedisCommandEditor: React.FC = ({ connectionId, redisDB }) => { - const { connections } = useStore(); + const connections = useStore(state => state.connections); + const theme = useStore(state => state.theme); + const appearance = useStore(state => state.appearance); const connection = connections.find(c => c.id === connectionId); + const darkMode = theme === 'dark'; + 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([]); @@ -294,11 +332,11 @@ const RedisCommandEditor: React.FC = ({ connectionId, r const delta = e.clientY - dragRef.current.startY; let newHeight = dragRef.current.startHeight + delta; - // 限制高度 - const minHeight = 100; - const maxHeight = containerRef.current ? containerRef.current.clientHeight - 100 : 800; - if (newHeight < minHeight) newHeight = minHeight; - if (newHeight > maxHeight) newHeight = maxHeight; + // 限制输入区高度,避免拖拽后压缩掉底部输出区。 + newHeight = clampRedisCommandEditorHeight( + newHeight, + containerRef.current?.clientHeight, + ); setEditorHeight(newHeight); @@ -323,13 +361,36 @@ const RedisCommandEditor: React.FC = ({ connectionId, r } return ( -
+
{/* Editor Top Pane */} -
-
+
+
- Redis Console - db{redisDB} + Redis Console + db{redisDB}
-
+
= ({ connectionId, r {/* Resizer Handle */}
-
+
{/* Results Terminal Bottom Pane */} -
-
- Execution Output - +
+
+ Execution Output +
-
+
{results.length === 0 ? ( -
+
在此终端执行命令,结果会以原样输出
- Tips: 选中任意行Ctrl + Enter 仅执行选中段落 + Tips: 选中任意行Ctrl + Enter 仅执行选中段落
) : ( @@ -402,7 +487,7 @@ const RedisCommandEditor: React.FC = ({ connectionId, r
{item.command} - [{item.durationMs}ms] + [{item.durationMs}ms]