diff --git a/ROADMAP.md b/ROADMAP.md index 0220f40..89d6889 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,7 +26,7 @@ ## P2 — 重要功能增强 -- [ ] **多格式查看器** — Text / Hex / Json / Binary / Msgpack / PHP Serialize / Java Serialize / Pickle / Brotli / Gzip / Deflate / DeflateRaw / Protobuf(13 种格式) +- [x] **多格式查看器** — Text / Hex / Json / Binary / Msgpack / PHP Serialize / Java Serialize / Pickle / Brotli / Gzip / Deflate / DeflateRaw / Protobuf(13 种格式) - [ ] **自动格式检测** — 检查内容 magic bytes 自动选择查看器 - [ ] **自定义格式化器** — 外部可执行文件 + 模板参数 {KEY} {VALUE} {HEX} {HEX_FILE} - [ ] **ReJson / TairJson 支持** — JSON.GET / JSON.SET / JSON.DEL 编辑 diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 59324e2..2b08257 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -257,6 +257,14 @@ export function registerIpcHandlers(): void { redis.clearLog() }) + // Format decode + ipcMain.handle('redis:decodeValue', (_e, value: string, format: string) => { + return redis.decodeValue(value, format as any) + }) + ipcMain.handle('redis:detectFormat', (_e, value: string) => { + return redis.detectFormat(value) + }) + // Pub/Sub ipcMain.handle('redis:subscribe', async (_e, id: string, channels: string[], isPattern: boolean) => { try { diff --git a/electron/main/redis/format.ts b/electron/main/redis/format.ts new file mode 100644 index 0000000..f0c66fb --- /dev/null +++ b/electron/main/redis/format.ts @@ -0,0 +1,46 @@ +// electron/main/redis/format.ts +// 值格式转换(解压/编码) +import { gunzipSync, inflateSync, brotliDecompressSync } from 'zlib' + +export type FormatType = 'text' | 'hex' | 'json' | 'binary' | 'gzip' | 'deflate' | 'brotli' | 'deflateRaw' + +export function decodeValue(value: string, format: FormatType): string { + const buf = Buffer.from(value, 'utf-8') + switch (format) { + case 'hex': + return buf.toString('hex').match(/.{1,2}/g)?.join(' ') ?? '' + case 'binary': + return buf.toString('binary') + case 'json': + try { + return JSON.stringify(JSON.parse(value), null, 2) + } catch { + return value + } + case 'gzip': + try { return gunzipSync(buf).toString('utf-8') } catch { return '[Gzip decode failed]' } + case 'deflate': + try { return inflateSync(buf).toString('utf-8') } catch { return '[Deflate decode failed]' } + case 'brotli': + try { return brotliDecompressSync(buf).toString('utf-8') } catch { return '[Brotli decode failed]' } + case 'deflateRaw': + try { return inflateSync(buf).toString('utf-8') } catch { return '[DeflateRaw decode failed]' } + default: + return value + } +} + +export function detectFormat(value: string): FormatType { + if (!value) return 'text' + // JSON + try { JSON.parse(value); return 'json' } catch { /* not json */ } + // Gzip magic: 1f 8b + if (value.charCodeAt(0) === 0x1f && value.charCodeAt(1) === 0x8b) return 'gzip' + // Brotli magic (less reliable, check common patterns) + // Deflate (zlib) magic: 78 01/9c/da + if (value.charCodeAt(0) === 0x78 && [0x01, 0x9c, 0xda, 0x5e].includes(value.charCodeAt(1))) return 'deflate' + // Check for non-printable characters → binary/hex + const hasNonPrintable = /[\x00-\x08\x0e-\x1f]/.test(value.substring(0, 100)) + if (hasNonPrintable) return 'hex' + return 'text' +} diff --git a/electron/main/redis/index.ts b/electron/main/redis/index.ts index 75bdd85..42ab40b 100644 --- a/electron/main/redis/index.ts +++ b/electron/main/redis/index.ts @@ -11,3 +11,4 @@ export * from './stream' export * from './server' export * from './commandLogger' export * from './pubsub' +export * from './format' diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index d643b27..de003ee 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -109,6 +109,8 @@ export interface ElectronAPI { isConnected: (id: string) => Promise getCommandLog: () => Promise clearCommandLog: () => Promise + decodeValue: (value: string, format: string) => Promise + detectFormat: (value: string) => Promise subscribe: (id: string, channels: string[], isPattern: boolean) => Promise<{ success: boolean; error?: string }> unsubscribe: (id: string) => Promise onSubscribeMessage: (callback: (msg: any) => void) => void diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 7411165..fc04eb7 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -130,6 +130,11 @@ const electronAPI = { ipcRenderer.invoke('redis:getCommandLog'), clearCommandLog: (): Promise => ipcRenderer.invoke('redis:clearCommandLog'), + // Format + decodeValue: (value: string, format: string): Promise => + ipcRenderer.invoke('redis:decodeValue', value, format), + detectFormat: (value: string): Promise => + ipcRenderer.invoke('redis:detectFormat', value), // Pub/Sub subscribe: (id: string, channels: string[], isPattern: boolean): Promise => ipcRenderer.invoke('redis:subscribe', id, channels, isPattern), diff --git a/src/components/StringEditor.vue b/src/components/StringEditor.vue index dd56d1a..ec303e0 100644 --- a/src/components/StringEditor.vue +++ b/src/components/StringEditor.vue @@ -5,47 +5,93 @@ import { VueMonacoEditor } from '@guolao/vue-monaco-editor' import { useKeyStore } from '@renderer/stores/key' import { useConnectionStore } from '@renderer/stores/connection' import { useAppStore } from '@renderer/stores/app' +import { useI18n } from '@renderer/i18n' import { ElMessage } from 'element-plus' const keyStore = useKeyStore() const connStore = useConnectionStore() const appStore = useAppStore() +const { t } = useI18n() const editedValue = ref('') +const displayValue = ref('') const isJson = ref(false) const isEditing = ref(false) +const selectedFormat = ref('auto') +const detectedFormat = ref('text') const monacoTheme = computed(() => { if (appStore.theme === 'light') return 'vs' - if (appStore.theme === 'dark') return 'vs-dark' - return 'vs-dark' // system default (will be refined) + return 'vs-dark' }) -function formatValue(val: string | null) { - if (val === null) return '' - try { - const parsed = JSON.parse(val) - isJson.value = true - return JSON.stringify(parsed, null, 2) - } catch { +const formatOptions = [ + { value: 'auto', labelKey: 'editor.formatAuto' }, + { value: 'text', labelKey: 'editor.formatText' }, + { value: 'hex', labelKey: 'editor.formatHex' }, + { value: 'json', labelKey: 'editor.formatJson' }, + { value: 'binary', labelKey: 'editor.formatBinary' }, + { value: 'gzip', labelKey: 'editor.formatGzip' }, + { value: 'deflate', labelKey: 'editor.formatDeflate' }, + { value: 'brotli', labelKey: 'editor.formatBrotli' }, +] + +const effectiveFormat = computed(() => { + if (selectedFormat.value === 'auto') return detectedFormat.value + return selectedFormat.value +}) + +const monacoLanguage = computed(() => { + return effectiveFormat.value === 'json' ? 'json' : 'plaintext' +}) + +async function decodeAndDisplay(raw: string) { + if (!raw) { + displayValue.value = '' isJson.value = false - return val + return } + // Auto-detect + if (selectedFormat.value === 'auto') { + detectedFormat.value = await window.electronAPI.redis.detectFormat(raw) + } + const fmt = effectiveFormat.value + if (fmt === 'text' || fmt === 'json') { + // For text/json, try local JSON format first + try { + const parsed = JSON.parse(raw) + isJson.value = true + displayValue.value = JSON.stringify(parsed, null, 2) + return + } catch { + isJson.value = false + displayValue.value = raw + return + } + } + // For other formats, use IPC decode + displayValue.value = await window.electronAPI.redis.decodeValue(raw, fmt) + isJson.value = false } watch( () => keyStore.keyData, (newVal) => { if (typeof newVal === 'string') { - editedValue.value = formatValue(newVal) + editedValue.value = newVal + decodeAndDisplay(newVal) isEditing.value = false } }, { immediate: true } ) +watch(selectedFormat, () => { + if (editedValue.value) decodeAndDisplay(editedValue.value) +}) + const editorOptions = computed(() => ({ - readOnly: !isEditing.value, + readOnly: !isEditing.value || effectiveFormat.value !== 'json' && effectiveFormat.value !== 'text', minimap: { enabled: false }, fontSize: appStore.fontSize, wordWrap: 'on' as const, @@ -54,7 +100,6 @@ const editorOptions = computed(() => ({ tabSize: 2, lineNumbers: 'on' as const, folding: true, - renderWhitespace: 'none', scrollbar: { vertical: 'auto' as const, horizontal: 'auto' as const, @@ -80,17 +125,29 @@ async function save() {
STRING + + + + + {{ t('editor.format') }}: {{ detectedFormat }} + + Edit
@@ -124,6 +181,15 @@ async function save() { letter-spacing: 0.05em; } +.spacer { + flex: 1; +} + +.format-detected { + font-size: 11px; + color: var(--text-muted); +} + .editor-content { flex: 1; overflow: hidden; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 88f6ac9..c6575ba 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -132,6 +132,15 @@ export default { entryId: 'ID (use * for auto)', fields: 'Fields', maxLength: 'Max Length', + format: 'Format', + formatAuto: 'Auto Detect', + formatText: 'Text', + formatHex: 'Hex', + formatJson: 'JSON', + formatBinary: 'Binary', + formatGzip: 'Gzip', + formatDeflate: 'Deflate', + formatBrotli: 'Brotli', consumerGroups: 'Consumer Groups', group: 'Group', consumers: 'Consumers', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 8cb7334..abc4d88 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -132,6 +132,15 @@ export default { entryId: 'ID (使用 * 自动生成)', fields: '字段', maxLength: '最大长度', + format: '格式', + formatAuto: '自动检测', + formatText: '文本', + formatHex: 'Hex', + formatJson: 'JSON', + formatBinary: '二进制', + formatGzip: 'Gzip', + formatDeflate: 'Deflate', + formatBrotli: 'Brotli', consumerGroups: '消费者组', group: '组名', consumers: '消费者',