feat: multi-format viewer (Text/Hex/JSON/Binary/Gzip/Deflate/Brotli)
- New format.ts: decodeValue + detectFormat using Node.js zlib - IPC: redis:decodeValue, redis:detectFormat - StringEditor: format selector dropdown with auto-detect - Monaco editor shows decoded value, read-only for compressed formats - Auto-detect: JSON, Gzip (1f 8b), Deflate (78 xx), non-printable → Hex - i18n keys for all formats (zh-CN + en)
This commit is contained in:
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
## P2 — 重要功能增强
|
## 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 自动选择查看器
|
- [ ] **自动格式检测** — 检查内容 magic bytes 自动选择查看器
|
||||||
- [ ] **自定义格式化器** — 外部可执行文件 + 模板参数 {KEY} {VALUE} {HEX} {HEX_FILE}
|
- [ ] **自定义格式化器** — 外部可执行文件 + 模板参数 {KEY} {VALUE} {HEX} {HEX_FILE}
|
||||||
- [ ] **ReJson / TairJson 支持** — JSON.GET / JSON.SET / JSON.DEL 编辑
|
- [ ] **ReJson / TairJson 支持** — JSON.GET / JSON.SET / JSON.DEL 编辑
|
||||||
|
|||||||
@@ -257,6 +257,14 @@ export function registerIpcHandlers(): void {
|
|||||||
redis.clearLog()
|
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
|
// Pub/Sub
|
||||||
ipcMain.handle('redis:subscribe', async (_e, id: string, channels: string[], isPattern: boolean) => {
|
ipcMain.handle('redis:subscribe', async (_e, id: string, channels: string[], isPattern: boolean) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -11,3 +11,4 @@ export * from './stream'
|
|||||||
export * from './server'
|
export * from './server'
|
||||||
export * from './commandLogger'
|
export * from './commandLogger'
|
||||||
export * from './pubsub'
|
export * from './pubsub'
|
||||||
|
export * from './format'
|
||||||
|
|||||||
Vendored
+2
@@ -109,6 +109,8 @@ export interface ElectronAPI {
|
|||||||
isConnected: (id: string) => Promise<boolean>
|
isConnected: (id: string) => Promise<boolean>
|
||||||
getCommandLog: () => Promise<any[]>
|
getCommandLog: () => Promise<any[]>
|
||||||
clearCommandLog: () => Promise<void>
|
clearCommandLog: () => Promise<void>
|
||||||
|
decodeValue: (value: string, format: string) => Promise<string>
|
||||||
|
detectFormat: (value: string) => Promise<string>
|
||||||
subscribe: (id: string, channels: string[], isPattern: boolean) => Promise<{ success: boolean; error?: string }>
|
subscribe: (id: string, channels: string[], isPattern: boolean) => Promise<{ success: boolean; error?: string }>
|
||||||
unsubscribe: (id: string) => Promise<void>
|
unsubscribe: (id: string) => Promise<void>
|
||||||
onSubscribeMessage: (callback: (msg: any) => void) => void
|
onSubscribeMessage: (callback: (msg: any) => void) => void
|
||||||
|
|||||||
@@ -130,6 +130,11 @@ const electronAPI = {
|
|||||||
ipcRenderer.invoke('redis:getCommandLog'),
|
ipcRenderer.invoke('redis:getCommandLog'),
|
||||||
clearCommandLog: (): Promise<void> =>
|
clearCommandLog: (): Promise<void> =>
|
||||||
ipcRenderer.invoke('redis:clearCommandLog'),
|
ipcRenderer.invoke('redis:clearCommandLog'),
|
||||||
|
// Format
|
||||||
|
decodeValue: (value: string, format: string): Promise<string> =>
|
||||||
|
ipcRenderer.invoke('redis:decodeValue', value, format),
|
||||||
|
detectFormat: (value: string): Promise<string> =>
|
||||||
|
ipcRenderer.invoke('redis:detectFormat', value),
|
||||||
// Pub/Sub
|
// Pub/Sub
|
||||||
subscribe: (id: string, channels: string[], isPattern: boolean): Promise<any> =>
|
subscribe: (id: string, channels: string[], isPattern: boolean): Promise<any> =>
|
||||||
ipcRenderer.invoke('redis:subscribe', id, channels, isPattern),
|
ipcRenderer.invoke('redis:subscribe', id, channels, isPattern),
|
||||||
|
|||||||
@@ -5,47 +5,93 @@ import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
|||||||
import { useKeyStore } from '@renderer/stores/key'
|
import { useKeyStore } from '@renderer/stores/key'
|
||||||
import { useConnectionStore } from '@renderer/stores/connection'
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
import { useAppStore } from '@renderer/stores/app'
|
import { useAppStore } from '@renderer/stores/app'
|
||||||
|
import { useI18n } from '@renderer/i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const keyStore = useKeyStore()
|
const keyStore = useKeyStore()
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const editedValue = ref('')
|
const editedValue = ref('')
|
||||||
|
const displayValue = ref('')
|
||||||
const isJson = ref(false)
|
const isJson = ref(false)
|
||||||
const isEditing = ref(false)
|
const isEditing = ref(false)
|
||||||
|
const selectedFormat = ref('auto')
|
||||||
|
const detectedFormat = ref('text')
|
||||||
|
|
||||||
const monacoTheme = computed(() => {
|
const monacoTheme = computed(() => {
|
||||||
if (appStore.theme === 'light') return 'vs'
|
if (appStore.theme === 'light') return 'vs'
|
||||||
if (appStore.theme === 'dark') return 'vs-dark'
|
return 'vs-dark'
|
||||||
return 'vs-dark' // system default (will be refined)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatValue(val: string | null) {
|
const formatOptions = [
|
||||||
if (val === null) return ''
|
{ 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
|
||||||
|
}
|
||||||
|
// 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 {
|
try {
|
||||||
const parsed = JSON.parse(val)
|
const parsed = JSON.parse(raw)
|
||||||
isJson.value = true
|
isJson.value = true
|
||||||
return JSON.stringify(parsed, null, 2)
|
displayValue.value = JSON.stringify(parsed, null, 2)
|
||||||
|
return
|
||||||
} catch {
|
} catch {
|
||||||
isJson.value = false
|
isJson.value = false
|
||||||
return val
|
displayValue.value = raw
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// For other formats, use IPC decode
|
||||||
|
displayValue.value = await window.electronAPI.redis.decodeValue(raw, fmt)
|
||||||
|
isJson.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => keyStore.keyData,
|
() => keyStore.keyData,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
if (typeof newVal === 'string') {
|
if (typeof newVal === 'string') {
|
||||||
editedValue.value = formatValue(newVal)
|
editedValue.value = newVal
|
||||||
|
decodeAndDisplay(newVal)
|
||||||
isEditing.value = false
|
isEditing.value = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(selectedFormat, () => {
|
||||||
|
if (editedValue.value) decodeAndDisplay(editedValue.value)
|
||||||
|
})
|
||||||
|
|
||||||
const editorOptions = computed(() => ({
|
const editorOptions = computed(() => ({
|
||||||
readOnly: !isEditing.value,
|
readOnly: !isEditing.value || effectiveFormat.value !== 'json' && effectiveFormat.value !== 'text',
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
fontSize: appStore.fontSize,
|
fontSize: appStore.fontSize,
|
||||||
wordWrap: 'on' as const,
|
wordWrap: 'on' as const,
|
||||||
@@ -54,7 +100,6 @@ const editorOptions = computed(() => ({
|
|||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
lineNumbers: 'on' as const,
|
lineNumbers: 'on' as const,
|
||||||
folding: true,
|
folding: true,
|
||||||
renderWhitespace: 'none',
|
|
||||||
scrollbar: {
|
scrollbar: {
|
||||||
vertical: 'auto' as const,
|
vertical: 'auto' as const,
|
||||||
horizontal: 'auto' as const,
|
horizontal: 'auto' as const,
|
||||||
@@ -80,17 +125,29 @@ async function save() {
|
|||||||
<div class="string-editor">
|
<div class="string-editor">
|
||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
<span class="type-badge">STRING</span>
|
<span class="type-badge">STRING</span>
|
||||||
|
<el-select v-model="selectedFormat" size="small" style="width:140px">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in formatOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
:label="t(opt.labelKey)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<span v-if="selectedFormat === 'auto'" class="format-detected">
|
||||||
|
{{ t('editor.format') }}: {{ detectedFormat }}
|
||||||
|
</span>
|
||||||
|
<span class="spacer" />
|
||||||
<el-button v-if="!isEditing" size="small" type="primary" @click="isEditing = true">Edit</el-button>
|
<el-button v-if="!isEditing" size="small" type="primary" @click="isEditing = true">Edit</el-button>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-button size="small" type="primary" @click="save">Save</el-button>
|
<el-button size="small" type="primary" @click="save">Save</el-button>
|
||||||
<el-button size="small" @click="isEditing = false; editedValue = formatValue(keyStore.keyData as string)">Cancel</el-button>
|
<el-button size="small" @click="isEditing = false; decodeAndDisplay(editedValue)">Cancel</el-button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-content">
|
<div class="editor-content">
|
||||||
<vue-monaco-editor
|
<vue-monaco-editor
|
||||||
v-model:value="editedValue"
|
v-model:value="displayValue"
|
||||||
:theme="monacoTheme"
|
:theme="monacoTheme"
|
||||||
:language="isJson ? 'json' : 'plaintext'"
|
:language="monacoLanguage"
|
||||||
:options="editorOptions"
|
:options="editorOptions"
|
||||||
height="100%"
|
height="100%"
|
||||||
/>
|
/>
|
||||||
@@ -124,6 +181,15 @@ async function save() {
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-detected {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-content {
|
.editor-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ export default {
|
|||||||
entryId: 'ID (use * for auto)',
|
entryId: 'ID (use * for auto)',
|
||||||
fields: 'Fields',
|
fields: 'Fields',
|
||||||
maxLength: 'Max Length',
|
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',
|
consumerGroups: 'Consumer Groups',
|
||||||
group: 'Group',
|
group: 'Group',
|
||||||
consumers: 'Consumers',
|
consumers: 'Consumers',
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ export default {
|
|||||||
entryId: 'ID (使用 * 自动生成)',
|
entryId: 'ID (使用 * 自动生成)',
|
||||||
fields: '字段',
|
fields: '字段',
|
||||||
maxLength: '最大长度',
|
maxLength: '最大长度',
|
||||||
|
format: '格式',
|
||||||
|
formatAuto: '自动检测',
|
||||||
|
formatText: '文本',
|
||||||
|
formatHex: 'Hex',
|
||||||
|
formatJson: 'JSON',
|
||||||
|
formatBinary: '二进制',
|
||||||
|
formatGzip: 'Gzip',
|
||||||
|
formatDeflate: 'Deflate',
|
||||||
|
formatBrotli: 'Brotli',
|
||||||
consumerGroups: '消费者组',
|
consumerGroups: '消费者组',
|
||||||
group: '组名',
|
group: '组名',
|
||||||
consumers: '消费者',
|
consumers: '消费者',
|
||||||
|
|||||||
Reference in New Issue
Block a user