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:
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
- [x] **多格式查看器** — 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 种格式)
|
||||||
- [x] **自动格式检测** — 检查内容 magic bytes 自动选择查看器
|
- [x] **自动格式检测** — 检查内容 magic bytes 自动选择查看器
|
||||||
- [ ] **自定义格式化器** — 外部可执行文件 + 模板参数 {KEY} {VALUE} {HEX} {HEX_FILE}
|
- [x] **自定义格式化器** — 外部可执行文件 + 模板参数 {KEY} {VALUE} {HEX} {HEX_FILE}
|
||||||
- [ ] **ReJson / TairJson 支持** — JSON.GET / JSON.SET / JSON.DEL 编辑
|
- [ ] **ReJson / TairJson 支持** — JSON.GET / JSON.SET / JSON.DEL 编辑
|
||||||
- [ ] **二进制 key 支持** — [Hex] 前缀输入,hex ↔ buffer 双向转换
|
- [ ] **二进制 key 支持** — [Hex] 前缀输入,hex ↔ buffer 双向转换
|
||||||
- [ ] **虚拟化滚动** — 大 key 集合(200k+)的性能优化,KeyList 树形视图虚拟化
|
- [ ] **虚拟化滚动** — 大 key 集合(200k+)的性能优化,KeyList 树形视图虚拟化
|
||||||
|
|||||||
@@ -264,6 +264,9 @@ export function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle('redis:detectFormat', (_e, value: string) => {
|
ipcMain.handle('redis:detectFormat', (_e, value: string) => {
|
||||||
return redis.detectFormat(value)
|
return redis.detectFormat(value)
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('redis:customFormat', (_e, value: string, command: string, key: string) => {
|
||||||
|
return redis.customFormat(value, command, key)
|
||||||
|
})
|
||||||
|
|
||||||
// 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) => {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
// electron/main/redis/format.ts
|
// electron/main/redis/format.ts
|
||||||
// 值格式转换(解压/编码)
|
// 值格式转换(解压/编码/自定义)
|
||||||
import { gunzipSync, inflateSync, brotliDecompressSync } from 'zlib'
|
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'
|
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'
|
if (hasNonPrintable) return 'hex'
|
||||||
return 'text'
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -111,6 +111,7 @@ export interface ElectronAPI {
|
|||||||
clearCommandLog: () => Promise<void>
|
clearCommandLog: () => Promise<void>
|
||||||
decodeValue: (value: string, format: string) => Promise<string>
|
decodeValue: (value: string, format: string) => Promise<string>
|
||||||
detectFormat: (value: 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 }>
|
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
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ const electronAPI = {
|
|||||||
ipcRenderer.invoke('redis:decodeValue', value, format),
|
ipcRenderer.invoke('redis:decodeValue', value, format),
|
||||||
detectFormat: (value: string): Promise<string> =>
|
detectFormat: (value: string): Promise<string> =>
|
||||||
ipcRenderer.invoke('redis:detectFormat', value),
|
ipcRenderer.invoke('redis:detectFormat', value),
|
||||||
|
customFormat: (value: string, command: string, key: string): Promise<string> =>
|
||||||
|
ipcRenderer.invoke('redis:customFormat', value, command, key),
|
||||||
// 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),
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ const formatOptions = [
|
|||||||
{ value: 'gzip', labelKey: 'editor.formatGzip' },
|
{ value: 'gzip', labelKey: 'editor.formatGzip' },
|
||||||
{ value: 'deflate', labelKey: 'editor.formatDeflate' },
|
{ value: 'deflate', labelKey: 'editor.formatDeflate' },
|
||||||
{ value: 'brotli', labelKey: 'editor.formatBrotli' },
|
{ value: 'brotli', labelKey: 'editor.formatBrotli' },
|
||||||
|
{ value: 'custom', labelKey: 'editor.formatCustom' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const customCommand = ref(localStorage.getItem('customFormatter') || '/bin/cat {HEX_FILE}')
|
||||||
|
|
||||||
const effectiveFormat = computed(() => {
|
const effectiveFormat = computed(() => {
|
||||||
if (selectedFormat.value === 'auto') return detectedFormat.value
|
if (selectedFormat.value === 'auto') return detectedFormat.value
|
||||||
return selectedFormat.value
|
return selectedFormat.value
|
||||||
@@ -70,6 +73,11 @@ async function decodeAndDisplay(raw: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// For other formats, use IPC decode
|
// 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)
|
displayValue.value = await window.electronAPI.redis.decodeValue(raw, fmt)
|
||||||
isJson.value = false
|
isJson.value = false
|
||||||
}
|
}
|
||||||
@@ -90,6 +98,8 @@ watch(selectedFormat, () => {
|
|||||||
if (editedValue.value) decodeAndDisplay(editedValue.value)
|
if (editedValue.value) decodeAndDisplay(editedValue.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(customCommand, (v) => localStorage.setItem('customFormatter', v))
|
||||||
|
|
||||||
const editorOptions = computed(() => ({
|
const editorOptions = computed(() => ({
|
||||||
readOnly: !isEditing.value || effectiveFormat.value !== 'json' && effectiveFormat.value !== 'text',
|
readOnly: !isEditing.value || effectiveFormat.value !== 'json' && effectiveFormat.value !== 'text',
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
@@ -133,6 +143,13 @@ async function save() {
|
|||||||
:label="t(opt.labelKey)"
|
:label="t(opt.labelKey)"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</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">
|
<span v-if="selectedFormat === 'auto'" class="format-detected">
|
||||||
{{ t('editor.format') }}: {{ detectedFormat }}
|
{{ t('editor.format') }}: {{ detectedFormat }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ export default {
|
|||||||
formatGzip: 'Gzip',
|
formatGzip: 'Gzip',
|
||||||
formatDeflate: 'Deflate',
|
formatDeflate: 'Deflate',
|
||||||
formatBrotli: 'Brotli',
|
formatBrotli: 'Brotli',
|
||||||
|
formatCustom: 'Custom',
|
||||||
|
customCommand: 'Custom Command',
|
||||||
|
customCommandHint: 'Templates: {KEY} {VALUE} {HEX_FILE}',
|
||||||
consumerGroups: 'Consumer Groups',
|
consumerGroups: 'Consumer Groups',
|
||||||
group: 'Group',
|
group: 'Group',
|
||||||
consumers: 'Consumers',
|
consumers: 'Consumers',
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ export default {
|
|||||||
formatGzip: 'Gzip',
|
formatGzip: 'Gzip',
|
||||||
formatDeflate: 'Deflate',
|
formatDeflate: 'Deflate',
|
||||||
formatBrotli: 'Brotli',
|
formatBrotli: 'Brotli',
|
||||||
|
formatCustom: '自定义',
|
||||||
|
customCommand: '自定义命令',
|
||||||
|
customCommandHint: '模板: {KEY} {VALUE} {HEX_FILE}',
|
||||||
consumerGroups: '消费者组',
|
consumerGroups: '消费者组',
|
||||||
group: '组名',
|
group: '组名',
|
||||||
consumers: '消费者',
|
consumers: '消费者',
|
||||||
|
|||||||
Reference in New Issue
Block a user