Files
MyGoNavi/frontend/src/components/RedisViewer.tsx
Syngnat 6b67bb24b4 feat(tool-center): 优化工具中心分组交互与通用弹窗
- 重构工具中心为侧边分组导航,固定弹窗高度并支持内部滚动
- 新增通用可拖拽可缩放 Modal,统一主要弹窗打开体验
- 为工具中心内嵌入口补充返回上一步交互与底部操作区
- 补充多语言文案和工具中心/Modal/i18n 回归测试
2026-06-18 20:28:47 +08:00

2167 lines
111 KiB
TypeScript

import Modal from './common/ResizableDraggableModal';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Table, Input, Button, Space, Tag, Tree, Spin, message, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
import type { RadioChangeEvent } from 'antd';
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
import Editor from './MonacoEditor';
import type { DataNode } from 'antd/es/tree';
import {
blurToFilter,
isMacLikePlatform,
normalizeBlurForPlatform,
normalizeOpacityForPlatform,
resolveAppearanceValues,
resolveTextInputSafeBackdropFilter,
} from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
applyRenamedRedisKeyState,
applyTreeNodeCheck,
buildLeafNodeKey,
buildCheckedTreeNodeState,
buildRedisKeyTree,
isGroupFullyChecked,
parseRawKeyFromNodeKey,
type RedisTreeDataNode,
} from './redisViewerTree';
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput, type RedisSearchMode } from '../utils/redisSearchPattern';
import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay';
import { t, type I18nParams } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
const { Search } = Input;
const REDIS_TREE_KEY_TYPE_WIDTH = 92;
const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84;
const REDIS_TREE_KEY_TTL_WIDTH = 92;
const REDIS_TREE_HIDE_TTL_THRESHOLD = 460;
const REDIS_KEY_INITIAL_LOAD_COUNT = 2000;
const REDIS_KEY_LOAD_MORE_COUNT = 2000;
const REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT = 600;
const REDIS_KEY_SEARCH_LOAD_MORE_COUNT = 1000;
const REDIS_LARGE_KEYSPACE_THRESHOLD = 10000;
const REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS = 200;
const REDIS_KEY_GONE_MESSAGE = 'Redis Key 不存在或已过期'; // i18n-scan: allow-raw backend sentinel
interface RedisViewerProps {
connectionId: string;
redisDB: number;
}
// Draggable divider uses direct DOM updates to avoid resize lag.
const ResizableDivider: React.FC<{
onResizeEnd: (newWidth: number) => void;
targetRef: React.RefObject<HTMLDivElement>;
minWidth?: number;
title: string;
}> = ({ onResizeEnd, targetRef, minWidth = 300, title }) => {
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const target = targetRef.current;
if (!target) return;
const startX = e.clientX;
const startWidth = target.offsetWidth;
const containerWidth = target.parentElement?.offsetWidth || window.innerWidth;
const maxWidth = containerWidth - 350; // Keep at least 350px for the right pane.
// Add an overlay to prevent text selection and other interactions.
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;cursor:col-resize;z-index:9999;';
document.body.appendChild(overlay);
let currentWidth = startWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
moveEvent.preventDefault();
const delta = moveEvent.clientX - startX;
currentWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + delta));
// Update DOM directly during drag without forcing React re-renders.
target.style.width = `${currentWidth}px`;
target.style.flexBasis = `${currentWidth}px`;
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.removeChild(overlay);
// Commit React state only after drag ends.
onResizeEnd(currentWidth);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
return (
<div
onMouseDown={handleMouseDown}
style={{
width: 5,
cursor: 'col-resize',
background: 'transparent',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
}}
title={title}
>
</div>
);
};
const getRedisScanLoadCount = (pattern: string, append: boolean): number => {
const normalizedPattern = pattern.trim() || '*';
if (normalizedPattern === '*') {
return append ? REDIS_KEY_LOAD_MORE_COUNT : REDIS_KEY_INITIAL_LOAD_COUNT;
}
return append ? REDIS_KEY_SEARCH_LOAD_MORE_COUNT : REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT;
};
const normalizeRedisCursor = (value: unknown): string => {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed === '' ? '0' : trimmed;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
return '0';
}
return Math.trunc(value).toString();
}
if (typeof value === 'bigint') {
return value.toString();
}
return '0';
};
const isRedisKeyGoneErrorMessage = (messageText: string): boolean => {
return messageText.includes(REDIS_KEY_GONE_MESSAGE);
};
const normalizeToolbarText = (value: unknown): string => String(value || '').trim();
const resolveRedisTopology = (connection?: { config?: { topology?: string; hosts?: string[] } }): 'single' | 'replica' | 'cluster' | 'sentinel' => {
const topology = normalizeToolbarText(connection?.config?.topology).toLowerCase();
if (topology === 'replica') return 'replica';
if (topology === 'sentinel') return 'sentinel';
if (topology === 'cluster') return 'cluster';
const extraHosts = Array.isArray(connection?.config?.hosts) ? connection.config.hosts.filter(Boolean) : [];
return extraHosts.length > 0 ? 'cluster' : 'single';
};
const buildRedisSeedAddresses = (connection?: { config?: { host?: string; port?: number | string; hosts?: string[] } }): string[] => {
if (!connection) return [];
const port = Number.isFinite(Number(connection.config?.port)) ? Number(connection.config?.port) : 6379;
const primaryHost = normalizeToolbarText(connection.config?.host);
const primary = primaryHost ? `${primaryHost}:${port}` : '';
const extraHosts = Array.isArray(connection.config?.hosts)
? connection.config.hosts.map((host) => normalizeToolbarText(host)).filter(Boolean)
: [];
return [primary, ...extraHosts].filter(Boolean);
};
const getRedisTopologyTagLabel = (topology: 'single' | 'replica' | 'cluster' | 'sentinel'): string => {
if (topology === 'replica') return 'Replica';
if (topology === 'cluster') return 'Cluster';
if (topology === 'sentinel') return 'Sentinel';
return 'Single';
};
const RedisViewer: React.FC<RedisViewerProps> = ({ 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 = useCallback((key: string, params?: I18nParams) => t(key, params, i18nLanguage), [i18nLanguage]);
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const blur = normalizeBlurForPlatform(resolvedAppearance.blur);
const disableLocalBackdropFilter = isMacLikePlatform();
const connection = connections.find(c => c.id === connectionId);
const workbenchTheme = useMemo(
() => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }),
[blur, darkMode, disableLocalBackdropFilter, opacity, appearance.uiVersion],
);
const workbenchBackdropFilter = useMemo(
() => resolveTextInputSafeBackdropFilter(blurToFilter(blur), disableLocalBackdropFilter),
[blur, disableLocalBackdropFilter],
);
const isV2Ui = appearance.uiVersion === 'v2';
const keyAccentColor = workbenchTheme.accent;
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
const valueToolbarBg = workbenchTheme.panelBgStrong;
const valueToolbarBorder = workbenchTheme.panelBorder;
const valueToolbarText = workbenchTheme.textMuted;
const redisTopology = useMemo(() => resolveRedisTopology(connection), [connection]);
const redisSeedAddresses = useMemo(() => buildRedisSeedAddresses(connection), [connection]);
const redisSentinelMaster = normalizeToolbarText(connection?.config?.redisSentinelMaster);
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
const [loading, setLoading] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [searchPattern, setSearchPattern] = useState('*');
const [searchMode, setSearchMode] = useState<RedisSearchMode>('fuzzy');
const [cursor, setCursor] = useState<string>('0');
const [hasMore, setHasMore] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyValue, setKeyValue] = useState<RedisValue | null>(null);
const [valueLoading, setValueLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [newKeyModalOpen, setNewKeyModalOpen] = useState(false);
const [newKeyForm] = Form.useForm();
const [renameKeyModalOpen, setRenameKeyModalOpen] = useState(false);
const [renameKeyForm] = Form.useForm();
const [renameTargetKey, setRenameTargetKey] = useState<string | null>(null);
const [ttlModalOpen, setTtlModalOpen] = useState(false);
const [ttlForm] = Form.useForm();
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [editValue, setEditValue] = useState('');
const [treeContextMenu, setTreeContextMenu] = useState<{ x: number; y: number; rawKey: string } | null>(null);
// View mode shared by every Redis value type.
const [viewMode, setViewMode] = useState<'auto' | 'text' | 'utf8' | 'hex'>('auto');
// JSON edit modal state.
const [jsonEditModalOpen, setJsonEditModalOpen] = useState(false);
const [jsonEditConfig, setJsonEditConfig] = useState<{
title: string;
value: string;
isJson: boolean;
onSave: (newValue: string) => Promise<void>;
} | null>(null);
const jsonEditValueRef = useRef<string>('');
const latestLoadRequestIdRef = useRef(0);
// Left pane width defaults to 50%.
const [leftPanelWidth, setLeftPanelWidth] = useState<number | string>('50%');
const leftPanelRef = useRef<HTMLDivElement>(null);
const treeContainerRef = useRef<HTMLDivElement>(null);
const [showTreeKeyTTL, setShowTreeKeyTTL] = useState(true);
const [treeHeight, setTreeHeight] = useState(500);
const [expandedGroupKeys, setExpandedGroupKeys] = useState<string[]>([]);
const workbenchCardStyle = useMemo(() => ({
background: workbenchTheme.panelBg,
border: workbenchTheme.panelBorder,
boxShadow: `${workbenchTheme.panelInset}, ${workbenchTheme.shadow}`,
borderRadius: 18,
backdropFilter: workbenchTheme.backdropFilter,
WebkitBackdropFilter: workbenchTheme.backdropFilter,
}), [workbenchTheme]);
const workbenchSubCardStyle = useMemo(() => ({
background: workbenchTheme.panelBgStrong,
border: workbenchTheme.panelBorder,
boxShadow: workbenchTheme.panelInset,
borderRadius: 16,
backdropFilter: workbenchTheme.backdropFilter,
WebkitBackdropFilter: workbenchTheme.backdropFilter,
}), [workbenchTheme]);
const actionButtonStyle = useMemo(() => ({
height: 36,
borderRadius: 12,
background: workbenchTheme.actionSecondaryBg,
borderColor: workbenchTheme.actionSecondaryBorder,
color: workbenchTheme.textPrimary,
fontWeight: 600,
boxShadow: 'none',
}), [workbenchTheme]);
const primaryActionButtonStyle = useMemo(() => ({
...actionButtonStyle,
background: workbenchTheme.toolbarPrimaryBg,
borderColor: workbenchTheme.accentBorder,
color: workbenchTheme.accent,
}), [actionButtonStyle, workbenchTheme]);
const dangerActionButtonStyle = useMemo(() => ({
...actionButtonStyle,
background: workbenchTheme.actionDangerBg,
borderColor: workbenchTheme.actionDangerBorder,
color: workbenchTheme.actionDangerText,
}), [actionButtonStyle, workbenchTheme]);
const pillTagStyle = useMemo(() => ({
margin: 0,
borderRadius: 999,
borderColor: workbenchTheme.statusTagBorder,
background: workbenchTheme.statusTagBg,
color: workbenchTheme.isDark ? '#9bc2ff' : '#165dca',
fontWeight: 600,
paddingInline: 10,
}), [workbenchTheme]);
const mutedPillTagStyle = useMemo(() => ({
margin: 0,
borderRadius: 999,
borderColor: workbenchTheme.statusTagMutedBorder,
background: workbenchTheme.statusTagMutedBg,
color: workbenchTheme.textSecondary,
fontWeight: 500,
paddingInline: 10,
}), [workbenchTheme]);
const redisModalContentStyle = useMemo(() => ({
background: workbenchTheme.panelBgStrong,
border: workbenchTheme.panelBorder,
boxShadow: `${workbenchTheme.panelInset}, ${workbenchTheme.shadow}`,
backdropFilter: workbenchTheme.backdropFilter,
WebkitBackdropFilter: workbenchTheme.backdropFilter,
}), [workbenchTheme]);
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 loadKeys = useCallback(async (
pattern: string = '*',
fromCursor: string = '0',
append: boolean = false,
targetCount?: number
) => {
const config = getConfig();
if (!config) return;
const normalizedPattern = pattern.trim() || '*';
const effectiveTargetCount = targetCount ?? getRedisScanLoadCount(normalizedPattern, append);
const requestId = latestLoadRequestIdRef.current + 1;
latestLoadRequestIdRef.current = requestId;
setLoading(true);
try {
const res = await (window as any).go.app.App.RedisScanKeys(buildRpcConnectionConfig(config), normalizedPattern, fromCursor, effectiveTargetCount);
if (requestId !== latestLoadRequestIdRef.current) {
return;
}
if (res.success) {
const result = res.data;
const scannedKeys = Array.isArray(result?.keys) ? result.keys : [];
const nextCursor = normalizeRedisCursor(result?.cursor);
if (append) {
setKeys(prev => {
const keyMap = new Map<string, RedisKeyInfo>();
prev.forEach(item => keyMap.set(item.key, item));
scannedKeys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
return Array.from(keyMap.values());
});
} else {
setKeys(scannedKeys);
}
setCursor(nextCursor);
setHasMore(nextCursor !== '0');
} else {
message.error(tr('redis_viewer.message.load_keys_failed', { detail: res.message }));
}
} catch (e: any) {
if (requestId !== latestLoadRequestIdRef.current) {
return;
}
message.error(tr('redis_viewer.message.load_keys_failed', { detail: e?.message || String(e) }));
} finally {
if (requestId === latestLoadRequestIdRef.current) {
setLoading(false);
}
}
}, [getConfig, tr]);
useEffect(() => {
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
}, [loadKeys, redisDB]);
const executeSearch = useCallback((value: string, mode: RedisSearchMode = searchMode) => {
const normalized = normalizeRedisSearchInput(value, mode);
setSearchInput(normalized.keyword);
setSearchPattern(normalized.pattern);
setCursor('0');
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
}, [loadKeys, searchMode]);
const handleSearch = (value: string) => {
executeSearch(value);
};
const handleSearchInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const normalized = normalizeRedisSearchDraftChange(event.target.value, searchMode);
setSearchInput(normalized.keyword);
if (!normalized.shouldSearchImmediately) {
return;
}
setSearchPattern(normalized.pattern);
setCursor('0');
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
};
const handleSearchModeChange = useCallback((event: RadioChangeEvent) => {
const nextMode = event.target.value as RedisSearchMode;
setSearchMode(nextMode);
executeSearch(searchInput, nextMode);
}, [executeSearch, searchInput]);
const handleLoadMore = () => {
if (!hasMore || loading) {
return;
}
loadKeys(searchPattern, cursor, true, getRedisScanLoadCount(searchPattern, true));
};
const handleRefresh = () => {
setCursor('0');
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
};
const handleSelectAllLoadedKeys = useCallback(() => {
setSelectedKeys(keys.map((item) => item.key));
}, [keys]);
const handleClearAllSelectedKeys = useCallback(() => {
setSelectedKeys([]);
}, []);
const removeMissingKeyFromView = useCallback((missingKey: string) => {
setKeys(prev => prev.filter(item => item.key !== missingKey));
setSelectedKeys(prev => prev.filter(item => item !== missingKey));
setSelectedKey(null);
setKeyValue(null);
}, []);
const loadKeyValue = async (key: string) => {
const config = getConfig();
if (!config) return;
setValueLoading(true);
try {
const res = await (window as any).go.app.App.RedisGetValue(buildRpcConnectionConfig(config), key);
if (res.success) {
setKeyValue(res.data);
setSelectedKey(key);
} else {
const messageText = String(res.message || '');
if (isRedisKeyGoneErrorMessage(messageText)) {
removeMissingKeyFromView(key);
message.warning(tr('redis_viewer.message.key_missing_removed'));
} else {
message.error(tr('redis_viewer.message.value_load_failed', { detail: messageText }));
}
}
} catch (e: any) {
const messageText = e?.message || String(e);
if (isRedisKeyGoneErrorMessage(messageText)) {
removeMissingKeyFromView(key);
message.warning(tr('redis_viewer.message.key_missing_removed'));
} else {
message.error(tr('redis_viewer.message.value_load_failed', { detail: messageText }));
}
} finally {
setValueLoading(false);
}
};
const handleDeleteKeys = async (keysToDelete: string[]) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisDeleteKeys(buildRpcConnectionConfig(config), keysToDelete);
if (res.success) {
message.success(tr('redis_viewer.message.deleted_keys', { count: res.data.deleted }));
setKeys(prev => prev.filter(k => !keysToDelete.includes(k.key)));
if (selectedKey && keysToDelete.includes(selectedKey)) {
setSelectedKey(null);
setKeyValue(null);
}
setSelectedKeys([]);
} else {
message.error(tr('redis_viewer.message.delete_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.delete_failed', { detail: e?.message || String(e) }));
}
};
const handleDeleteCurrentKey = async () => {
if (!selectedKey) return;
await handleDeleteKeys([selectedKey]);
};
const handleSetTTL = async () => {
const config = getConfig();
if (!config || !selectedKey) return;
try {
const values = await ttlForm.validateFields();
const res = await (window as any).go.app.App.RedisSetTTL(buildRpcConnectionConfig(config), selectedKey, values.ttl);
if (res.success) {
message.success(tr('redis_viewer.message.ttl_set_success'));
setTtlModalOpen(false);
loadKeyValue(selectedKey);
handleRefresh();
} else {
message.error(tr('redis_viewer.message.set_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.set_failed', { detail: e?.message || String(e) }));
}
};
const handleSaveString = async () => {
const config = getConfig();
if (!config || !selectedKey) return;
try {
const res = await (window as any).go.app.App.RedisSetString(buildRpcConnectionConfig(config), selectedKey, editValue, keyValue?.ttl || -1);
if (res.success) {
message.success(tr('redis_viewer.message.save_success'));
setEditModalOpen(false);
loadKeyValue(selectedKey);
} else {
message.error(tr('redis_viewer.message.save_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.save_failed', { detail: e?.message || String(e) }));
}
};
const handleCreateKey = async () => {
const config = getConfig();
if (!config) return;
try {
const values = await newKeyForm.validateFields();
const res = await (window as any).go.app.App.RedisSetString(buildRpcConnectionConfig(config), values.key, values.value, values.ttl || -1);
if (res.success) {
message.success(tr('redis_viewer.message.create_success'));
setNewKeyModalOpen(false);
newKeyForm.resetFields();
handleRefresh();
} else {
message.error(tr('redis_viewer.message.create_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.create_failed', { detail: e?.message || String(e) }));
}
};
const openRenameKeyModal = useCallback((rawKey: string) => {
setTreeContextMenu(null);
setRenameTargetKey(rawKey);
renameKeyForm.setFieldsValue({ key: rawKey });
setRenameKeyModalOpen(true);
}, [renameKeyForm]);
const handleRenameKey = async () => {
const config = getConfig();
if (!config || !renameTargetKey) return;
try {
const values = await renameKeyForm.validateFields();
const nextKey = String(values.key || '').trim();
if (!nextKey) {
message.warning(tr('redis_viewer.message.new_key_name_required'));
return;
}
if (nextKey === renameTargetKey) {
message.warning(tr('redis_viewer.message.rename_same_key'));
return;
}
const existsRes = await (window as any).go.app.App.RedisKeyExists(buildRpcConnectionConfig(config), nextKey);
if (!existsRes?.success) {
message.error(tr('redis_viewer.message.key_check_failed', { detail: existsRes?.message || 'Unknown error' }));
return;
}
if (existsRes?.data?.exists) {
message.error(tr('redis_viewer.message.target_key_exists', { key: nextKey }));
return;
}
const res = await (window as any).go.app.App.RedisRenameKey(buildRpcConnectionConfig(config), renameTargetKey, nextKey);
if (res.success) {
const nextState = applyRenamedRedisKeyState(
{
keys,
selectedKey,
selectedKeys,
},
renameTargetKey,
nextKey
);
setKeys(nextState.keys);
setSelectedKey(nextState.selectedKey);
setSelectedKeys(Array.from(new Set(nextState.selectedKeys)));
setRenameKeyModalOpen(false);
setRenameTargetKey(null);
renameKeyForm.resetFields();
message.success(tr('redis_viewer.message.rename_success'));
if (selectedKey === renameTargetKey) {
void loadKeyValue(nextKey);
}
handleRefresh();
} else {
message.error(tr('redis_viewer.message.rename_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.rename_failed', { detail: e?.message || String(e) }));
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'string': return 'green';
case 'hash': return 'blue';
case 'list': return 'orange';
case 'set': return 'purple';
case 'zset': return 'magenta';
case 'stream': return 'cyan';
default: return 'default';
}
};
const formatTTL = useCallback((ttl: number) => {
if (ttl === -1) return tr('redis_viewer.ttl.forever');
if (ttl === -2) return tr('redis_viewer.ttl.expired');
if (ttl < 60) return tr('redis_viewer.ttl.seconds', { seconds: ttl });
if (ttl < 3600) return tr('redis_viewer.ttl.minutes_seconds', { minutes: Math.floor(ttl / 60), seconds: ttl % 60 });
if (ttl < 86400) return tr('redis_viewer.ttl.hours_minutes', { hours: Math.floor(ttl / 3600), minutes: Math.floor((ttl % 3600) / 60) });
return tr('redis_viewer.ttl.days_hours', { days: Math.floor(ttl / 86400), hours: Math.floor((ttl % 86400) / 3600) });
}, [tr]);
useEffect(() => {
const target = leftPanelRef.current;
if (!target) return;
const updateTTLVisibility = (width: number) => {
const nextShowTTL = width > REDIS_TREE_HIDE_TTL_THRESHOLD;
setShowTreeKeyTTL((prev) => (prev === nextShowTTL ? prev : nextShowTTL));
};
updateTTLVisibility(Math.round(target.getBoundingClientRect().width));
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver((entries) => {
const width = Math.round(entries[0]?.contentRect.width || target.getBoundingClientRect().width);
updateTTLVisibility(width);
});
observer.observe(target);
return () => observer.disconnect();
}
const handleWindowResize = () => {
updateTTLVisibility(Math.round(target.getBoundingClientRect().width));
};
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
useEffect(() => {
const target = treeContainerRef.current;
if (!target) return;
const updateTreeHeight = (nextHeight: number) => {
if (nextHeight <= 0) return;
setTreeHeight((prev) => (prev === nextHeight ? prev : nextHeight));
};
updateTreeHeight(Math.round(target.getBoundingClientRect().height));
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver((entries) => {
const nextHeight = Math.round(entries[0]?.contentRect.height || target.getBoundingClientRect().height);
updateTreeHeight(nextHeight);
});
observer.observe(target);
return () => observer.disconnect();
}
const handleWindowResize = () => {
updateTreeHeight(Math.round(target.getBoundingClientRect().height));
};
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
const isLargeKeyspace = keys.length >= REDIS_LARGE_KEYSPACE_THRESHOLD;
const keyTree = useMemo(() => {
return buildRedisKeyTree(keys, !isLargeKeyspace);
}, [isLargeKeyspace, keys]);
const groupKeySet = useMemo(() => new Set(keyTree.groupKeys), [keyTree.groupKeys]);
const selectedTreeNodeKeys = useMemo(() => {
if (!selectedKey) {
return [] as string[];
}
return [buildLeafNodeKey(selectedKey)];
}, [selectedKey]);
const checkedTreeNodeKeys = useMemo(() => {
return buildCheckedTreeNodeState(selectedKeys, keyTree);
}, [keyTree, selectedKeys]);
useEffect(() => {
const existingKeySet = new Set(keys.map(item => item.key));
setSelectedKeys(prev => prev.filter(rawKey => existingKeySet.has(rawKey)));
}, [keys]);
useEffect(() => {
setExpandedGroupKeys((prev) => {
const validKeys = prev.filter(nodeKey => groupKeySet.has(nodeKey));
if (!isLargeKeyspace) {
return validKeys;
}
return validKeys.slice(0, REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS);
});
}, [groupKeySet, isLargeKeyspace]);
useEffect(() => {
if (!treeContextMenu) {
return;
}
const handleDismiss = () => setTreeContextMenu(null);
window.addEventListener('click', handleDismiss);
window.addEventListener('scroll', handleDismiss, true);
window.addEventListener('contextmenu', handleDismiss);
return () => {
window.removeEventListener('click', handleDismiss);
window.removeEventListener('scroll', handleDismiss, true);
window.removeEventListener('contextmenu', handleDismiss);
};
}, [treeContextMenu]);
const handleTreeSelect = (nodeKeys: React.Key[]) => {
if (nodeKeys.length === 0) {
return;
}
const rawKey = parseRawKeyFromNodeKey(nodeKeys[0]);
if (!rawKey) {
return;
}
loadKeyValue(rawKey);
};
const handleTreeCheck = (
_checked: React.Key[] | { checked: React.Key[]; halfChecked: React.Key[] },
info: { checked: boolean; node: DataNode }
) => {
const node = info.node as RedisTreeDataNode;
setSelectedKeys((prev) => applyTreeNodeCheck(prev, node, info.checked));
};
const handleTreeRightClick = ({ event, node }: { event: React.MouseEvent; node: DataNode }) => {
event.preventDefault();
event.stopPropagation();
const treeNode = node as RedisTreeDataNode;
if (treeNode.nodeType !== 'leaf' || !treeNode.rawKey) {
setTreeContextMenu(null);
return;
}
setTreeContextMenu({
x: event.clientX,
y: event.clientY,
rawKey: treeNode.rawKey,
});
};
const handleSelectGroupDescendants = useCallback((treeNode: RedisTreeDataNode) => {
setSelectedKeys((prev) => applyTreeNodeCheck(prev, treeNode, !isGroupFullyChecked(treeNode, prev)));
}, []);
const handleToggleGroupExpand = useCallback((groupNodeKey: string) => {
setExpandedGroupKeys((prev) => {
const exists = prev.includes(groupNodeKey);
const nextKeys = exists
? prev.filter((nodeKey) => nodeKey !== groupNodeKey)
: [...prev, groupNodeKey];
if (isLargeKeyspace) {
return nextKeys.slice(-REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS);
}
return nextKeys;
});
}, [isLargeKeyspace]);
const stopTreeTitleEvent = (event: React.SyntheticEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
};
const renderTreeNodeTitle = useCallback((nodeData: DataNode) => {
const treeNode = nodeData as RedisTreeDataNode;
if (treeNode.nodeType === 'group') {
const groupFullyChecked = isGroupFullyChecked(treeNode, selectedKeys);
const groupNodeKey = String(treeNode.key ?? '');
const isExpanded = expandedGroupKeys.includes(groupNodeKey);
return (
<div
role="button"
tabIndex={0}
onMouseDown={stopTreeTitleEvent}
onClick={(event) => {
stopTreeTitleEvent(event);
handleToggleGroupExpand(groupNodeKey);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
stopTreeTitleEvent(event);
handleToggleGroupExpand(groupNodeKey);
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
width: '100%',
minWidth: 0,
padding: '2px 0',
cursor: 'pointer',
}}
>
<Space size={6} style={{ minWidth: 0, overflow: 'hidden' }}>
<button
type="button"
className="redis-tree-expander-button"
aria-label={isExpanded ? tr('redis_viewer.aria.collapse_group') : tr('redis_viewer.aria.expand_group')}
onMouseDown={stopTreeTitleEvent}
onClick={(event) => {
stopTreeTitleEvent(event);
handleToggleGroupExpand(groupNodeKey);
}}
style={{
width: 18,
height: 18,
padding: 0,
border: 'none',
background: 'transparent',
color: workbenchTheme.textMuted,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 6,
cursor: 'pointer',
flexShrink: 0,
}}
>
{isExpanded ? <DownOutlined style={{ fontSize: 11 }} /> : <RightOutlined style={{ fontSize: 11 }} />}
</button>
<FolderOpenOutlined style={{ color: workbenchTheme.textMuted }} />
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{treeNode.groupName}
</span>
<span style={{ fontSize: 12, color: workbenchTheme.textMuted, flexShrink: 0 }}>({treeNode.groupLeafCount ?? 0})</span>
</Space>
<Button
size="small"
style={{
paddingInline: 10,
height: 26,
borderRadius: 999,
flexShrink: 0,
borderColor: workbenchTheme.accentBorder,
background: workbenchTheme.accentSoft,
color: workbenchTheme.accent,
fontWeight: 600,
}}
onMouseDown={stopTreeTitleEvent}
onClick={(event) => {
stopTreeTitleEvent(event);
handleSelectGroupDescendants(treeNode);
}}
>
{groupFullyChecked ? tr('redis_viewer.action.clear_group_selection') : tr('redis_viewer.action.select_group')}
</Button>
</div>
);
}
const leafLabel = treeNode.leafLabel ?? '';
const rawKey = treeNode.rawKey ?? parseRawKeyFromNodeKey(treeNode.key ?? '') ?? '';
const keyType = treeNode.keyType ?? 'unknown';
const ttl = typeof treeNode.ttl === 'number' ? treeNode.ttl : -1;
if (isLargeKeyspace) {
return (
<div style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: workbenchTheme.textPrimary }}>
<span>{leafLabel}</span>
<span style={{ marginLeft: 8, color: workbenchTheme.textMuted, fontSize: 12 }}>[{keyType}]</span>
{showTreeKeyTTL && (
<span style={{ marginLeft: 8, color: workbenchTheme.textMuted, fontSize: 12 }}>{formatTTL(ttl)}</span>
)}
</div>
);
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
width: '100%',
overflow: 'hidden',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
minWidth: 0,
flex: 1,
overflow: 'hidden',
}}
>
<KeyOutlined style={{ color: keyAccentColor, flexShrink: 0 }} />
<Tooltip title={rawKey}>
<span
style={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{leafLabel}
</span>
</Tooltip>
</div>
<Tag
color={getTypeColor(keyType)}
style={{
marginInlineEnd: 0,
width: showTreeKeyTTL ? REDIS_TREE_KEY_TYPE_WIDTH : REDIS_TREE_KEY_TYPE_WIDTH_NARROW,
textAlign: 'center',
flexShrink: 0,
borderRadius: 999,
fontWeight: 600,
}}
>
{keyType}
</Tag>
{showTreeKeyTTL && (
<span
style={{
width: REDIS_TREE_KEY_TTL_WIDTH,
fontSize: 12,
color: workbenchTheme.textMuted,
textAlign: 'left',
whiteSpace: 'nowrap',
flexShrink: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{formatTTL(ttl)}
</span>
)}
</div>
);
}, [expandedGroupKeys, formatTTL, getTypeColor, handleSelectGroupDescendants, handleToggleGroupExpand, isLargeKeyspace, keyAccentColor, selectedKeys, showTreeKeyTTL, tr, workbenchTheme]);
const handleTreeExpand = (nextExpandedKeys: React.Key[]) => {
const validGroupKeys = nextExpandedKeys
.map(key => String(key))
.filter(nodeKey => groupKeySet.has(nodeKey));
if (isLargeKeyspace) {
setExpandedGroupKeys(validGroupKeys.slice(0, REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS));
return;
}
setExpandedGroupKeys(validGroupKeys);
};
const renderValueEditor = () => {
const processValueForCurrentView = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
}
if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
}
if (viewMode === 'utf8') {
return { displayValue: decodeRedisUtf8Value(value), isBinary: false, isJson: false, encoding: 'UTF-8' };
}
return formatRedisStringValue(value);
};
if (!keyValue || !selectedKey) {
return (
<div
className={isV2Ui ? 'gn-v2-redis-empty-value' : undefined}
style={{
...workbenchCardStyle,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: workbenchTheme.contentEmptyBg,
color: workbenchTheme.textMuted,
padding: 24,
}}
>
{tr('redis_viewer.state.empty_selection')}
</div>
);
}
const renderStringValue = () => {
const strValue = String(keyValue.value);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(strValue);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-subtoolbar' : undefined} style={{
padding: '4px 8px',
background: valueToolbarBg,
borderBottom: valueToolbarBorder,
display: 'flex',
alignItems: 'center'
}}>
<span style={{ fontSize: 12, color: valueToolbarText }}>
{encoding && tr('redis_viewer.label.encoding', { encoding })}
</span>
</div>
<Editor
height="calc(100% - 72px)"
gonaviTypography="data"
language={isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={displayValue}
options={{
readOnly: true,
minimap: { enabled: false },
lineNumbers: 'on',
wordWrap: isBinary ? 'off' : 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
folding: true,
formatOnPaste: true,
}}
/>
<div style={{ padding: '8px 0', flexShrink: 0 }}>
<Space>
<Button icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(strValue).then(() => {
message.success(tr('redis_viewer.message.copied'));
}).catch(() => {
message.error(tr('redis_viewer.message.copy_failed'));
});
}}>{tr('redis_viewer.action.copy')}</Button>
{!isBinary && viewMode === 'auto' && (
<Button icon={<EditOutlined />} onClick={() => {
setEditValue(displayValue);
setEditModalOpen(true);
}}>{tr('redis_viewer.action.edit')}</Button>
)}
{(isBinary || viewMode !== 'auto') && (
<span style={{ color: '#999', fontSize: 12 }}>
{viewMode !== 'auto' ? tr('redis_viewer.hint.switch_auto_to_edit') : tr('redis_viewer.hint.binary_readonly')}
</span>
)}
</Space>
</div>
</div>
);
};
const renderHashValue = () => {
const data = Object.entries(keyValue.value as Record<string, string>).map(([field, value]) => {
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value);
return { field, value, displayValue, isBinary, isJson, encoding };
});
const handleEditHashField = async (field: string, newValue: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisSetHashField(buildRpcConnectionConfig(config), selectedKey, field, newValue);
if (res.success) {
message.success(tr('redis_viewer.message.update_success'));
loadKeyValue(selectedKey);
} else {
message.error(tr('redis_viewer.message.update_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.update_failed', { detail: e?.message || String(e) }));
}
};
const handleDeleteHashField = async (field: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, [field]);
if (res.success) {
message.success(tr('redis_viewer.message.delete_success'));
loadKeyValue(selectedKey);
} else {
message.error(tr('redis_viewer.message.delete_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.delete_failed', { detail: e?.message || String(e) }));
}
};
return (
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: tr('redis_viewer.modal.add_field'),
content: (
<Form id="add-hash-field-form" layout="vertical">
<Form.Item label={tr('redis_viewer.field.field_name')} name="field" rules={[{ required: true }]}>
<Input id="new-hash-field" {...noAutoCapInputProps} />
</Form.Item>
<Form.Item label={tr('redis_viewer.field.value')} name="value" rules={[{ required: true }]}>
<Input.TextArea id="new-hash-value" rows={4} />
</Form.Item>
</Form>
),
onOk: async () => {
const field = (document.getElementById('new-hash-field') as HTMLInputElement)?.value;
const value = (document.getElementById('new-hash-value') as HTMLTextAreaElement)?.value;
if (field && value !== undefined) {
await handleEditHashField(field, value);
}
}
});
}}>{tr('redis_viewer.action.add_field')}</Button>
</div>
<Table
dataSource={data}
columns={[
{ title: tr('redis_viewer.table.field'), dataIndex: 'field', key: 'field', width: 200, ellipsis: true },
{
title: tr('redis_viewer.table.value'),
dataIndex: 'displayValue',
key: 'value',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'var(--gn-font-mono)' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: tr('redis_viewer.table.action'),
key: 'action',
width: 120,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title={tr('redis_viewer.tooltip.copy_value')}>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.value).then(() => {
message.success(tr('redis_viewer.message.copied'));
}).catch(() => {
message.error(tr('redis_viewer.message.copy_failed'));
});
}} />
</Tooltip>
{!record.isBinary && (
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => {
const editContent = record.isJson ? record.displayValue : record.value;
setJsonEditConfig({
title: tr('redis_viewer.modal.edit_field', { field: record.field }),
value: editContent,
isJson: record.isJson,
onSave: async (newValue: string) => {
await handleEditHashField(record.field, newValue);
}
});
setJsonEditModalOpen(true);
}} />
)}
<Popconfirm title={tr('redis_viewer.confirm.delete_field')} onConfirm={() => handleDeleteHashField(record.field)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]}
rowKey="field"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
const renderListValue = () => {
const data = (keyValue.value as string[]).map((value, index) => {
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value);
return { index, value, displayValue, isBinary, isJson, encoding };
});
const handleEditListItem = async (index: number, newValue: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisListSet(buildRpcConnectionConfig(config), selectedKey, index, newValue);
if (res.success) {
message.success(tr('redis_viewer.message.update_success'));
loadKeyValue(selectedKey);
} else {
message.error(tr('redis_viewer.message.update_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.update_failed', { detail: e?.message || String(e) }));
}
};
const handleAddListItem = async (value: string, position: 'left' | 'right') => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisListPush(buildRpcConnectionConfig(config), selectedKey, { values: [value], position });
if (res.success) {
message.success(tr('redis_viewer.message.add_success'));
loadKeyValue(selectedKey);
} else {
message.error(tr('redis_viewer.message.add_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.add_failed', { detail: e?.message || String(e) }));
}
};
return (
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: tr('redis_viewer.modal.add_element'),
content: (
<div>
<Input.TextArea id="new-list-value" rows={4} placeholder={tr('redis_viewer.placeholder.new_element_value')} />
</div>
),
onOk: async () => {
const value = (document.getElementById('new-list-value') as HTMLTextAreaElement)?.value;
if (value) {
await handleAddListItem(value, 'right');
}
}
});
}}>{tr('redis_viewer.action.add_list_tail')}</Button>
<Button size="small" style={actionButtonStyle} onClick={() => {
Modal.confirm({
title: tr('redis_viewer.modal.add_element_head'),
content: (
<div>
<Input.TextArea id="new-list-value-left" rows={4} placeholder={tr('redis_viewer.placeholder.new_element_value')} />
</div>
),
onOk: async () => {
const value = (document.getElementById('new-list-value-left') as HTMLTextAreaElement)?.value;
if (value) {
await handleAddListItem(value, 'left');
}
}
});
}}>{tr('redis_viewer.action.add_list_head')}</Button>
</Space>
</div>
<Table
dataSource={data}
columns={[
{ title: tr('redis_viewer.table.index'), dataIndex: 'index', key: 'index', width: 80 },
{
title: tr('redis_viewer.table.value'),
dataIndex: 'displayValue',
key: 'value',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'var(--gn-font-mono)' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: tr('redis_viewer.table.action'),
key: 'action',
width: 80,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title={tr('redis_viewer.tooltip.copy_value')}>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.value).then(() => {
message.success(tr('redis_viewer.message.copied'));
}).catch(() => {
message.error(tr('redis_viewer.message.copy_failed'));
});
}} />
</Tooltip>
{!record.isBinary && (
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => {
const editContent = record.isJson ? record.displayValue : record.value;
setJsonEditConfig({
title: tr('redis_viewer.modal.edit_index', { index: record.index }),
value: editContent,
isJson: record.isJson,
onSave: async (newValue: string) => {
await handleEditListItem(record.index, newValue);
}
});
setJsonEditModalOpen(true);
}} />
)}
</Space>
)
}
]}
rowKey="index"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
const renderSetValue = () => {
const data = (keyValue.value as string[]).map((member, index) => {
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(member);
return { index, member, displayValue, isBinary, isJson, encoding };
});
const handleAddSetMember = async (member: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisSetAdd(buildRpcConnectionConfig(config), selectedKey, [member]);
if (res.success) {
message.success(tr('redis_viewer.message.add_success'));
loadKeyValue(selectedKey);
} else {
message.error(tr('redis_viewer.message.add_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.add_failed', { detail: e?.message || String(e) }));
}
};
const handleRemoveSetMember = async (member: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisSetRemove(buildRpcConnectionConfig(config), selectedKey, [member]);
if (res.success) {
message.success(tr('redis_viewer.message.delete_success'));
loadKeyValue(selectedKey);
} else {
message.error(tr('redis_viewer.message.delete_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.delete_failed', { detail: e?.message || String(e) }));
}
};
return (
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: tr('redis_viewer.modal.add_member'),
content: (
<Input.TextArea id="new-set-member" rows={4} placeholder={tr('redis_viewer.placeholder.new_member_value')} />
),
onOk: async () => {
const member = (document.getElementById('new-set-member') as HTMLTextAreaElement)?.value;
if (member) {
await handleAddSetMember(member);
}
}
});
}}>{tr('redis_viewer.action.add_member')}</Button>
</div>
<Table
dataSource={data}
columns={[
{
title: tr('redis_viewer.table.member'),
dataIndex: 'displayValue',
key: 'member',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'var(--gn-font-mono)' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: tr('redis_viewer.table.action'),
key: 'action',
width: 80,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title={tr('redis_viewer.tooltip.copy_value')}>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.member).then(() => {
message.success(tr('redis_viewer.message.copied'));
}).catch(() => {
message.error(tr('redis_viewer.message.copy_failed'));
});
}} />
</Tooltip>
<Popconfirm title={tr('redis_viewer.confirm.delete_member')} onConfirm={() => handleRemoveSetMember(record.member)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]}
rowKey="index"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
const renderZSetValue = () => {
const data = (keyValue.value as Array<{ member: string; score: number }>).map((item, index) => {
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(item.member);
return { ...item, index, displayMember: displayValue, isBinary, isJson, encoding };
});
const handleAddZSetMember = async (member: string, score: number) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisZSetAdd(buildRpcConnectionConfig(config), selectedKey, [{ member, score }]);
if (res.success) {
message.success(tr('redis_viewer.message.add_success'));
loadKeyValue(selectedKey);
} else {
message.error(tr('redis_viewer.message.add_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.add_failed', { detail: e?.message || String(e) }));
}
};
const handleRemoveZSetMember = async (member: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisZSetRemove(buildRpcConnectionConfig(config), selectedKey, [member]);
if (res.success) {
message.success(tr('redis_viewer.message.delete_success'));
loadKeyValue(selectedKey);
} else {
message.error(tr('redis_viewer.message.delete_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.delete_failed', { detail: e?.message || String(e) }));
}
};
return (
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: tr('redis_viewer.modal.add_member'),
content: (
<div>
<div style={{ marginBottom: 8 }}>
<label>{tr('redis_viewer.field.score')}</label>
<InputNumber id="new-zset-score" defaultValue={0} style={{ width: '100%' }} />
</div>
<div>
<label>{tr('redis_viewer.field.member')}</label>
<Input.TextArea id="new-zset-member" rows={4} placeholder={tr('redis_viewer.placeholder.member_value')} />
</div>
</div>
),
onOk: async () => {
const score = parseFloat((document.getElementById('new-zset-score') as HTMLInputElement)?.value || '0');
const member = (document.getElementById('new-zset-member') as HTMLTextAreaElement)?.value;
if (member) {
await handleAddZSetMember(member, score);
}
}
});
}}>{tr('redis_viewer.action.add_member')}</Button>
</div>
<Table
dataSource={data}
columns={[
{ title: tr('redis_viewer.table.score'), dataIndex: 'score', key: 'score', width: 120 },
{
title: tr('redis_viewer.table.member'),
dataIndex: 'displayMember',
key: 'member',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'var(--gn-font-mono)' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: tr('redis_viewer.table.action'),
key: 'action',
width: 120,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title={tr('redis_viewer.tooltip.copy_value')}>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.member).then(() => {
message.success(tr('redis_viewer.message.copied'));
}).catch(() => {
message.error(tr('redis_viewer.message.copy_failed'));
});
}} />
</Tooltip>
{!record.isBinary && (
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => {
Modal.confirm({
title: tr('redis_viewer.modal.update_score'),
content: (
<div>
<label>{tr('redis_viewer.field.new_score')}</label>
<InputNumber id="edit-zset-score" defaultValue={record.score} style={{ width: '100%' }} />
</div>
),
onOk: async () => {
const newScore = parseFloat((document.getElementById('edit-zset-score') as HTMLInputElement)?.value || '0');
await handleAddZSetMember(record.member, newScore);
}
});
}} />
)}
<Popconfirm title={tr('redis_viewer.confirm.delete_member')} onConfirm={() => handleRemoveZSetMember(record.member)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]}
rowKey="index"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
const renderStreamValue = () => {
const data = (keyValue.value as StreamEntry[]).map((item, index) => {
const rawFieldsText = JSON.stringify(item.fields ?? {}, null, 2);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(rawFieldsText);
return {
index,
id: item.id,
rawFieldsText,
displayFields: displayValue,
isBinary,
isJson,
encoding,
};
});
const handleAddStreamEntry = async (fieldsText: string, id: string) => {
const config = getConfig();
if (!config) return;
let parsed: unknown;
try {
parsed = JSON.parse(fieldsText);
} catch (e) {
message.error(tr('redis_viewer.message.fields_json_invalid'));
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
message.error(tr('redis_viewer.message.fields_must_be_json_object'));
return;
}
const fieldMap: Record<string, string> = {};
Object.entries(parsed as Record<string, unknown>).forEach(([field, value]) => {
fieldMap[field] = value == null ? '' : String(value);
});
if (Object.keys(fieldMap).length === 0) {
message.error(tr('redis_viewer.message.fields_required'));
return;
}
try {
const res = await (window as any).go.app.App.RedisStreamAdd(buildRpcConnectionConfig(config), selectedKey, fieldMap, id || '*');
if (res.success) {
const newID = res.data?.id ? ` (${res.data.id})` : '';
message.success(tr('redis_viewer.message.add_success_with_id', { id: newID }));
loadKeyValue(selectedKey);
} else {
message.error(tr('redis_viewer.message.add_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.add_failed', { detail: e?.message || String(e) }));
}
};
const handleDeleteStreamEntry = async (id: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisStreamDelete(buildRpcConnectionConfig(config), selectedKey, [id]);
if (res.success) {
const deleted = Number(res.data?.deleted ?? 0);
if (deleted > 0) {
message.success(tr('redis_viewer.message.delete_success'));
} else {
message.warning(tr('redis_viewer.message.stream_entry_not_deleted'));
}
loadKeyValue(selectedKey);
} else {
message.error(tr('redis_viewer.message.delete_failed', { detail: res.message }));
}
} catch (e: any) {
message.error(tr('redis_viewer.message.delete_failed', { detail: e?.message || String(e) }));
}
};
return (
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: tr('redis_viewer.modal.add_stream_entry'),
width: 680,
content: (
<div>
<div style={{ marginBottom: 8 }}>
<label>{tr('redis_viewer.field.stream_id')}</label>
<Input id="new-stream-id" {...noAutoCapInputProps} placeholder={tr('redis_viewer.placeholder.stream_id')} />
</div>
<div>
<label>{tr('redis_viewer.field.fields_json')}</label>
<Input.TextArea id="new-stream-fields" rows={8} defaultValue={'{\n "field": "value"\n}'} />
</div>
</div>
),
onOk: async () => {
const id = (document.getElementById('new-stream-id') as HTMLInputElement)?.value?.trim() || '*';
const fieldsText = (document.getElementById('new-stream-fields') as HTMLTextAreaElement)?.value || '{}';
await handleAddStreamEntry(fieldsText, id);
}
});
}}>{tr('redis_viewer.action.add_stream_entry')}</Button>
</div>
<Table
dataSource={data}
columns={[
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 240,
ellipsis: true,
},
{
title: tr('redis_viewer.table.fields'),
dataIndex: 'displayFields',
key: 'fields',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 720 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'var(--gn-font-mono)' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: tr('redis_viewer.table.action'),
key: 'action',
width: 140,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title={tr('redis_viewer.tooltip.copy_id')}>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.id).then(() => {
message.success(tr('redis_viewer.message.copied'));
}).catch(() => {
message.error(tr('redis_viewer.message.copy_failed'));
});
}} />
</Tooltip>
<Tooltip title={tr('redis_viewer.tooltip.copy_fields_json')}>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.rawFieldsText).then(() => {
message.success(tr('redis_viewer.message.copied'));
}).catch(() => {
message.error(tr('redis_viewer.message.copy_failed'));
});
}} />
</Tooltip>
<Popconfirm title={tr('redis_viewer.confirm.delete_stream_entry')} onConfirm={() => handleDeleteStreamEntry(record.id)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]}
rowKey="id"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
return (
<div className={isV2Ui ? 'gn-v2-redis-value-layout' : undefined} style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className={isV2Ui ? 'gn-v2-redis-value-header' : undefined} style={{ ...workbenchCardStyle, padding: 18, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexShrink: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
<span style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>
{tr('redis_viewer.title.active_key')}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0 }}>
<Tooltip title={selectedKey}>
<strong style={{ maxWidth: 340, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 26, color: workbenchTheme.textPrimary }}>
{selectedKey}
</strong>
</Tooltip>
<Tooltip title={tr('redis_viewer.tooltip.copy_key_name')}>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
style={{ padding: '0 4px', display: 'flex', alignItems: 'center', color: workbenchTheme.textMuted }}
onClick={() => {
navigator.clipboard.writeText(selectedKey).then(() => {
message.success(tr('redis_viewer.message.key_name_copied'));
}).catch(() => {
message.error(tr('redis_viewer.message.copy_failed'));
});
}}
/>
</Tooltip>
<Tag color={getTypeColor(keyValue.type)} style={pillTagStyle}>{keyValue.type}</Tag>
<Tag icon={<ClockCircleOutlined />} style={mutedPillTagStyle}>{formatTTL(keyValue.ttl)}</Tag>
{keyValue.length > 0 && <Tag style={mutedPillTagStyle}>{tr('redis_viewer.label.length', { count: keyValue.length })}</Tag>}
</div>
</div>
<div className={isV2Ui ? 'gn-v2-redis-value-actions' : undefined} style={{ ...workbenchSubCardStyle, padding: 4, display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<Button size="small" style={actionButtonStyle} onClick={() => {
ttlForm.setFieldsValue({ ttl: keyValue.ttl > 0 ? keyValue.ttl : -1 });
setTtlModalOpen(true);
}}>{tr('redis_viewer.action.set_ttl')}</Button>
<Button size="small" style={actionButtonStyle} onClick={() => loadKeyValue(selectedKey)} icon={<ReloadOutlined />}>{tr('redis_viewer.action.refresh')}</Button>
<Popconfirm title={tr('redis_viewer.confirm.delete_key', { key: selectedKey })} onConfirm={handleDeleteCurrentKey}>
<Button size="small" style={dangerActionButtonStyle} icon={<DeleteOutlined />}>{tr('redis_viewer.action.delete_key')}</Button>
</Popconfirm>
</div>
</div>
<div className={isV2Ui ? 'gn-v2-redis-view-mode' : undefined} style={{ ...workbenchSubCardStyle, padding: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<span style={{ paddingInline: 10, fontSize: 12, color: workbenchTheme.textMuted }}>{tr('redis_viewer.view.title')}</span>
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
<Radio.Button value="auto">{tr('redis_viewer.view.auto')}</Radio.Button>
<Radio.Button value="text">{tr('redis_viewer.view.text')}</Radio.Button>
<Radio.Button value="utf8">UTF-8</Radio.Button>
<Radio.Button value="hex">{tr('redis_viewer.view.hex')}</Radio.Button>
</Radio.Group>
</div>
<div className={isV2Ui ? 'gn-v2-redis-value-card' : undefined} style={{ ...workbenchCardStyle, padding: 14, flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', height: '100%' }}>
{keyValue.type === 'string' && renderStringValue()}
{keyValue.type === 'hash' && renderHashValue()}
{keyValue.type === 'list' && renderListValue()}
{keyValue.type === 'set' && renderSetValue()}
{keyValue.type === 'zset' && renderZSetValue()}
{keyValue.type === 'stream' && renderStreamValue()}
</div>
</div>
</div>
);
};
if (!connection) {
return <div style={{ padding: 20 }}>{tr('redis_viewer.state.connection_not_found')}</div>;
}
return (
<div className={`redis-viewer-workbench${isV2Ui ? ' gn-v2-redis-workbench' : ''}`} style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
{/* Left: Key List */}
<div ref={leftPanelRef} className={isV2Ui ? 'gn-v2-redis-sidebar' : undefined} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
<div className={isV2Ui ? 'gn-v2-redis-header' : undefined} style={{ ...workbenchCardStyle, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
<div>
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>{tr('redis_viewer.title.key_explorer')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginTop: 4 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: workbenchTheme.textPrimary }}>db{redisDB}</div>
<Tag style={mutedPillTagStyle}>{getRedisTopologyTagLabel(redisTopology)}</Tag>
{redisTopology !== 'single' && (
<Tag style={mutedPillTagStyle}>{Math.max(redisSeedAddresses.length, 1)} nodes</Tag>
)}
{redisSentinelMaster && (
<Tag style={mutedPillTagStyle}>master: {redisSentinelMaster}</Tag>
)}
</div>
</div>
<Tag style={mutedPillTagStyle}>{tr('redis_viewer.label.keys_count', { count: keys.length })}</Tag>
</div>
<Space.Compact style={{ width: '100%' }}>
<Radio.Group
value={searchMode}
onChange={handleSearchModeChange}
buttonStyle="solid"
style={{ flexShrink: 0 }}
>
<Radio.Button value="fuzzy">{tr('redis_viewer.search.fuzzy')}</Radio.Button>
<Radio.Button value="exact">{tr('redis_viewer.search.exact')}</Radio.Button>
</Radio.Group>
<Search
{...noAutoCapInputProps}
style={{ flex: 1 }}
placeholder={searchMode === 'exact' ? tr('redis_viewer.placeholder.search_exact') : tr('redis_viewer.placeholder.search_fuzzy')}
value={searchInput}
onChange={handleSearchInputChange}
onSearch={handleSearch}
allowClear
enterButton={<SearchOutlined />}
/>
</Space.Compact>
<div className={isV2Ui ? 'gn-v2-redis-toolbar' : undefined} style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<Space wrap size={8}>
<Button size="small" style={actionButtonStyle} icon={<ReloadOutlined />} onClick={handleRefresh}>{tr('redis_viewer.action.refresh')}</Button>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => setNewKeyModalOpen(true)}>{tr('redis_viewer.action.new_key')}</Button>
<Button size="small" style={primaryActionButtonStyle} onClick={handleSelectAllLoadedKeys} disabled={keys.length === 0}>{tr('redis_viewer.action.select_all_loaded')}</Button>
<Button size="small" style={actionButtonStyle} onClick={handleClearAllSelectedKeys} disabled={selectedKeys.length === 0}>{tr('redis_viewer.action.clear_selection')}</Button>
</Space>
<Popconfirm
title={tr('redis_viewer.confirm.delete_selected', { count: selectedKeys.length })}
onConfirm={() => handleDeleteKeys(selectedKeys)}
disabled={selectedKeys.length === 0}
>
<Button size="small" style={dangerActionButtonStyle} icon={<DeleteOutlined />} disabled={selectedKeys.length === 0}>
{tr('redis_viewer.action.delete_selected', { count: selectedKeys.length })}
</Button>
</Popconfirm>
</div>
</div>
<div className={isV2Ui ? 'gn-v2-redis-tree-card' : undefined} style={{ ...workbenchCardStyle, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', padding: 10 }}>
{isLargeKeyspace && (
<div style={{ padding: '8px 10px', fontSize: 12, color: workbenchTheme.textMuted, marginBottom: 8, borderRadius: 12, background: workbenchTheme.panelBgSubtle, border: workbenchTheme.panelBorder }}>
{tr('redis_viewer.notice.large_keyspace_mode', { count: REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS })}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 8px 10px 8px', color: workbenchTheme.textMuted, fontSize: 12, textTransform: 'uppercase', letterSpacing: '.06em' }}>
<span>{tr('redis_viewer.title.namespace_key')}</span>
<span>{tr('redis_viewer.title.type_ttl')}</span>
</div>
<div ref={treeContainerRef} className={isV2Ui ? 'gn-v2-redis-tree-shell' : undefined} style={{ ...workbenchSubCardStyle, flex: 1, minHeight: 0, overflow: 'hidden', padding: 6 }}>
<Spin spinning={loading} size="small" style={{ width: '100%' }}>
<Tree
blockNode
showIcon={false}
switcherIcon={() => null}
checkable
checkStrictly
selectable
virtual
height={Math.max(treeHeight - 8, 220)}
treeData={keyTree.treeData}
titleRender={renderTreeNodeTitle}
selectedKeys={selectedTreeNodeKeys}
checkedKeys={checkedTreeNodeKeys}
expandedKeys={expandedGroupKeys}
onExpand={handleTreeExpand}
onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)}
onCheck={(checked, info) => handleTreeCheck(checked, info)}
onRightClick={handleTreeRightClick}
style={{ padding: '8px 6px' }}
/>
</Spin>
</div>
{hasMore && (
<div style={{ padding: 10, textAlign: 'center' }}>
<Button style={actionButtonStyle} onClick={handleLoadMore} loading={loading} disabled={!hasMore || loading}>{tr('redis_viewer.action.load_more')}</Button>
</div>
)}
</div>
</div>
{/* Resizable Divider */}
<ResizableDivider targetRef={leftPanelRef} onResizeEnd={setLeftPanelWidth} title={tr('redis_viewer.tooltip.resize_panels')} />
{/* Right: Value Viewer */}
<div className={isV2Ui ? 'gn-v2-redis-value-pane' : undefined} style={{ flex: 1, overflow: 'hidden', minWidth: 300 }}>
{valueLoading ? (
<div style={{ ...workbenchCardStyle, padding: 20, textAlign: 'center', color: workbenchTheme.textMuted }}>{tr('common.loading')}...</div>
) : (
renderValueEditor()
)}
</div>
{/* Edit String Modal */}
<Modal
title={tr('redis_viewer.modal.edit_value')}
open={editModalOpen}
onOk={handleSaveString}
onCancel={() => setEditModalOpen(false)}
width={800}
styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { height: 500, paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
>
<Editor
height="450px"
gonaviTypography="data"
language={formatRedisStringValue(editValue).isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={editValue}
onChange={(value) => setEditValue(value || '')}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
folding: true
}}
/>
</Modal>
{/* New Key Modal */}
<Modal
title={tr('redis_viewer.modal.new_key')}
open={newKeyModalOpen}
onOk={handleCreateKey}
onCancel={() => setNewKeyModalOpen(false)}
styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
>
<Form form={newKeyForm} layout="vertical" initialValues={{ ttl: -1 }}>
<Form.Item name="key" label={tr('redis_viewer.field.key')} rules={[{ required: true, message: tr('redis_viewer.validation.key_required') }]}>
<Input {...noAutoCapInputProps} placeholder={tr('redis_viewer.placeholder.key_name')} />
</Form.Item>
<Form.Item name="value" label={tr('redis_viewer.field.value')} rules={[{ required: true, message: tr('redis_viewer.validation.value_required') }]}>
<Input.TextArea rows={4} placeholder={tr('redis_viewer.placeholder.value')} />
</Form.Item>
<Form.Item name="ttl" label={tr('redis_viewer.field.ttl_seconds')} help={tr('redis_viewer.help.ttl_forever')}>
<InputNumber style={{ width: '100%' }} min={-1} />
</Form.Item>
</Form>
</Modal>
{/* TTL Modal */}
<Modal
title={tr('redis_viewer.modal.rename_key')}
open={renameKeyModalOpen}
onOk={handleRenameKey}
onCancel={() => {
setRenameKeyModalOpen(false);
setRenameTargetKey(null);
renameKeyForm.resetFields();
}}
styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
>
<Form form={renameKeyForm} layout="vertical">
<Form.Item
name="key"
label={tr('redis_viewer.field.new_key_name')}
rules={[{ required: true, message: tr('redis_viewer.validation.new_key_name_required') }]}
extra={renameTargetKey ? tr('redis_viewer.label.original_key', { key: renameTargetKey }) : undefined}
>
<Input {...noAutoCapInputProps} placeholder={tr('redis_viewer.placeholder.new_key_name')} />
</Form.Item>
</Form>
</Modal>
<Modal
title={tr('redis_viewer.modal.set_ttl')}
open={ttlModalOpen}
onOk={handleSetTTL}
onCancel={() => setTtlModalOpen(false)}
styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
>
<Form form={ttlForm} layout="vertical">
<Form.Item name="ttl" label={tr('redis_viewer.field.ttl_seconds')} help={tr('redis_viewer.help.ttl_forever')}>
<InputNumber style={{ width: '100%' }} min={-1} />
</Form.Item>
</Form>
</Modal>
{/* JSON Edit Modal with Monaco Editor */}
<Modal
title={jsonEditConfig?.title || tr('redis_viewer.action.edit')}
open={jsonEditModalOpen}
onOk={async () => {
if (jsonEditConfig?.onSave) {
await jsonEditConfig.onSave(jsonEditValueRef.current);
}
setJsonEditModalOpen(false);
}}
onCancel={() => setJsonEditModalOpen(false)}
width={800}
styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { height: 500, paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
>
<Editor
height="450px"
gonaviTypography="data"
language={jsonEditConfig?.isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
defaultValue={jsonEditConfig?.value || ''}
onChange={(value) => { jsonEditValueRef.current = value || ''; }}
onMount={(editor) => { jsonEditValueRef.current = jsonEditConfig?.value || ''; }}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
folding: true,
formatOnPaste: true
}}
/>
</Modal>
{treeContextMenu && typeof document !== 'undefined' && createPortal((
<div
className={isV2Ui ? 'gn-v2-context-menu gn-v2-redis-context-menu' : undefined}
style={{
position: 'fixed',
left: typeof window !== 'undefined' ? Math.min(treeContextMenu.x + 4, Math.max(16, window.innerWidth - 220)) : treeContextMenu.x,
top: typeof window !== 'undefined' ? Math.min(treeContextMenu.y + 4, Math.max(16, window.innerHeight - 140)) : treeContextMenu.y,
zIndex: 1200,
minWidth: 188,
padding: 8,
borderRadius: 14,
background: workbenchTheme.panelBgStrong,
border: workbenchTheme.panelBorder,
boxShadow: `${workbenchTheme.panelInset}, ${workbenchTheme.shadow}`,
backdropFilter: workbenchTheme.backdropFilter,
WebkitBackdropFilter: workbenchTheme.backdropFilter,
}}
onClick={(event) => event.stopPropagation()}
>
<Button
type="text"
style={{ width: '100%', justifyContent: 'flex-start', height: 40, borderRadius: 10, color: workbenchTheme.textPrimary, fontWeight: 600 }}
icon={<EditOutlined />}
onClick={() => openRenameKeyModal(treeContextMenu.rawKey)}
>
{tr('redis_viewer.action.rename_key')}
</Button>
<Button
type="text"
style={{ width: '100%', justifyContent: 'flex-start', height: 40, borderRadius: 10, color: workbenchTheme.textPrimary, fontWeight: 600 }}
icon={<CopyOutlined />}
onClick={async () => {
try {
await navigator.clipboard.writeText(treeContextMenu.rawKey);
setTreeContextMenu(null);
message.success(tr('redis_viewer.message.key_name_copied'));
} catch {
message.error(tr('redis_viewer.message.copy_failed'));
}
}}
>
{tr('redis_viewer.action.copy_key_name')}
</Button>
</div>
), document.body)}
</div>
);
};
export default RedisViewer;