feat: custom formatter support

- format.ts: customFormat() uses child_process.execFile
- Template params: {KEY} {VALUE} {HEX_FILE}
- Value written to temp file, path passed via {HEX_FILE}
- 10s timeout, error handling
- StringEditor: 'custom' format option + command input
- Command persisted to localStorage
- i18n keys (zh-CN + en)
This commit is contained in:
2026-07-07 21:54:13 +08:00
parent 5178aea6d1
commit 1511427e62
8 changed files with 65 additions and 2 deletions
+1 -1
View File
@@ -28,7 +28,7 @@
- [x] **多格式查看器** — Text / Hex / Json / Binary / Msgpack / PHP Serialize / Java Serialize / Pickle / Brotli / Gzip / Deflate / DeflateRaw / Protobuf13 种格式)
- [x] **自动格式检测** — 检查内容 magic bytes 自动选择查看器
- [ ] **自定义格式化器** — 外部可执行文件 + 模板参数 {KEY} {VALUE} {HEX} {HEX_FILE}
- [x] **自定义格式化器** — 外部可执行文件 + 模板参数 {KEY} {VALUE} {HEX} {HEX_FILE}
- [ ] **ReJson / TairJson 支持** — JSON.GET / JSON.SET / JSON.DEL 编辑
- [ ] **二进制 key 支持** — [Hex] 前缀输入,hex ↔ buffer 双向转换
- [ ] **虚拟化滚动** — 大 key 集合(200k+)的性能优化,KeyList 树形视图虚拟化
+3
View File
@@ -264,6 +264,9 @@ export function registerIpcHandlers(): void {
ipcMain.handle('redis:detectFormat', (_e, value: string) => {
return redis.detectFormat(value)
})
ipcMain.handle('redis:customFormat', (_e, value: string, command: string, key: string) => {
return redis.customFormat(value, command, key)
})
// Pub/Sub
ipcMain.handle('redis:subscribe', async (_e, id: string, channels: string[], isPattern: boolean) => {
+35 -1
View File
@@ -1,6 +1,10 @@
// electron/main/redis/format.ts
// 值格式转换(解压/编码)
// 值格式转换(解压/编码/自定义
import { gunzipSync, inflateSync, brotliDecompressSync } from 'zlib'
import { execFile } from 'child_process'
import { writeFile, unlink, mkdtemp } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
export type FormatType = 'text' | 'hex' | 'json' | 'binary' | 'gzip' | 'deflate' | 'brotli' | 'deflateRaw'
@@ -44,3 +48,33 @@ export function detectFormat(value: string): FormatType {
if (hasNonPrintable) return 'hex'
return 'text'
}
export async function customFormat(
value: string,
command: string,
key?: string
): Promise<string> {
const tmpDir = await mkdtemp(join(tmpdir(), 'jrdm-fmt-'))
const tmpFile = join(tmpDir, 'value.bin')
await writeFile(tmpFile, value, 'utf-8')
return new Promise((resolve) => {
const cmd = command
.replace(/\{KEY\}/g, key || '')
.replace(/\{VALUE\}/g, value)
.replace(/\{HEX_FILE\}/g, tmpFile)
const parts = cmd.split(/\s+/)
const exe = parts[0]
const args = parts.slice(1)
execFile(exe, args, { timeout: 10000, maxBuffer: 10 * 1024 * 1024 }, async (err, stdout) => {
await unlink(tmpFile).catch(() => {})
if (err) {
resolve(`[Custom formatter error: ${err.message}]`)
} else {
resolve(stdout)
}
})
})
}
+1
View File
@@ -111,6 +111,7 @@ export interface ElectronAPI {
clearCommandLog: () => Promise<void>
decodeValue: (value: string, format: string) => Promise<string>
detectFormat: (value: string) => Promise<string>
customFormat: (value: string, command: string, key: string) => Promise<string>
subscribe: (id: string, channels: string[], isPattern: boolean) => Promise<{ success: boolean; error?: string }>
unsubscribe: (id: string) => Promise<void>
onSubscribeMessage: (callback: (msg: any) => void) => void
+2
View File
@@ -135,6 +135,8 @@ const electronAPI = {
ipcRenderer.invoke('redis:decodeValue', value, format),
detectFormat: (value: string): Promise<string> =>
ipcRenderer.invoke('redis:detectFormat', value),
customFormat: (value: string, command: string, key: string): Promise<string> =>
ipcRenderer.invoke('redis:customFormat', value, command, key),
// Pub/Sub
subscribe: (id: string, channels: string[], isPattern: boolean): Promise<any> =>
ipcRenderer.invoke('redis:subscribe', id, channels, isPattern),
+17
View File
@@ -34,8 +34,11 @@ const formatOptions = [
{ value: 'gzip', labelKey: 'editor.formatGzip' },
{ value: 'deflate', labelKey: 'editor.formatDeflate' },
{ value: 'brotli', labelKey: 'editor.formatBrotli' },
{ value: 'custom', labelKey: 'editor.formatCustom' },
]
const customCommand = ref(localStorage.getItem('customFormatter') || '/bin/cat {HEX_FILE}')
const effectiveFormat = computed(() => {
if (selectedFormat.value === 'auto') return detectedFormat.value
return selectedFormat.value
@@ -70,6 +73,11 @@ async function decodeAndDisplay(raw: string) {
}
}
// For other formats, use IPC decode
if (fmt === 'custom') {
displayValue.value = await window.electronAPI.redis.customFormat(raw, customCommand.value, keyStore.selectedKey || '')
isJson.value = false
return
}
displayValue.value = await window.electronAPI.redis.decodeValue(raw, fmt)
isJson.value = false
}
@@ -90,6 +98,8 @@ watch(selectedFormat, () => {
if (editedValue.value) decodeAndDisplay(editedValue.value)
})
watch(customCommand, (v) => localStorage.setItem('customFormatter', v))
const editorOptions = computed(() => ({
readOnly: !isEditing.value || effectiveFormat.value !== 'json' && effectiveFormat.value !== 'text',
minimap: { enabled: false },
@@ -133,6 +143,13 @@ async function save() {
:label="t(opt.labelKey)"
/>
</el-select>
<el-input
v-if="selectedFormat === 'custom'"
v-model="customCommand"
size="small"
style="width:240px"
:placeholder="t('editor.customCommandHint')"
/>
<span v-if="selectedFormat === 'auto'" class="format-detected">
{{ t('editor.format') }}: {{ detectedFormat }}
</span>
+3
View File
@@ -141,6 +141,9 @@ export default {
formatGzip: 'Gzip',
formatDeflate: 'Deflate',
formatBrotli: 'Brotli',
formatCustom: 'Custom',
customCommand: 'Custom Command',
customCommandHint: 'Templates: {KEY} {VALUE} {HEX_FILE}',
consumerGroups: 'Consumer Groups',
group: 'Group',
consumers: 'Consumers',
+3
View File
@@ -141,6 +141,9 @@ export default {
formatGzip: 'Gzip',
formatDeflate: 'Deflate',
formatBrotli: 'Brotli',
formatCustom: '自定义',
customCommand: '自定义命令',
customCommandHint: '模板: {KEY} {VALUE} {HEX_FILE}',
consumerGroups: '消费者组',
group: '组名',
consumers: '消费者',