fix: patch all P2 quality/performance issues from project review
- ipc-handlers: withLog extracts actual connection ID from args[0] (P2#20) - updater: add initialized guard to prevent duplicate IPC registration (P2#25) - win-state: log writeFile errors instead of empty callback (P2#26) - format: detectFormat samples up to 4096 chars instead of 100 (P2#28) - commandLogger: add 30+ missing write commands to WRITE_COMMANDS (P2#32) - ReJsonEditor: bind Monaco theme to monacoTheme computed (P2#19) - keys: normalize empty SCAN pattern to '*' (P2#21) - SlowLogView: remove duplicate <style scoped> block (P2#29) - CliView: deduplicate XGROUP in writeCommands Set (P2#33) - key store: use connectionStore instead of IPC for separator (P2#22) - NewConnectionDialog: add visible guard after async watch (P2#30) - KeyList: remove dangerouslyUseHTMLString, use plain text + i18n (P2#31) - KeyList: batch DUMP export with Promise.allSettled (batch=20) (P2#23) - HashEditor: lazy load field TTL on click instead of bulk HTTL (P2#24) - P2#27 already fixed in P0#2; P2#34 is feature (N4); P2#35 not a bug - docs: update PROJECT_REVIEW.md with P2 fix status
This commit is contained in:
@@ -6,14 +6,15 @@ import * as redis from './redis'
|
||||
|
||||
// Helper: wrap handler with command logging
|
||||
function withLog(cmd: string, fn: (...args: any[]) => any) {
|
||||
return async (...args: any[]) => {
|
||||
return async (_e: any, ...args: any[]) => {
|
||||
const start = Date.now()
|
||||
const connId = args[0] || 'unknown'
|
||||
try {
|
||||
const result = await fn(...args)
|
||||
redis.log('current', cmd, Date.now() - start)
|
||||
const result = await fn(_e, ...args)
|
||||
redis.log(connId, cmd, Date.now() - start)
|
||||
return result
|
||||
} catch (err) {
|
||||
redis.log('current', cmd, Date.now() - start)
|
||||
redis.log(connId, cmd, Date.now() - start)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,15 @@ const WRITE_COMMANDS = new Set([
|
||||
'LPOP', 'RPOP', 'BLPOP', 'BRPOP',
|
||||
'ZINCRBY', 'ZREMRANGEBYRANK', 'ZREMRANGEBYSCORE',
|
||||
'UNLINK', 'MOVE', 'RENAMENX',
|
||||
'JSON.SET', 'JSON.DEL', 'JSON.ARRAPPEND', 'JSON.ARRINDEX', 'JSON.ARRINSERT',
|
||||
'JSON.ARRTRIM', 'JSON.ARRPOP', 'JSON.NUMINCRBY', 'JSON.STRAPPEND', 'JSON.STRLEN',
|
||||
'JSON.OBJSET', 'JSON.OBJDEL', 'COPY', 'RESTORE', 'MIGRATE',
|
||||
'PEXPIRE', 'PEXPIREAT', 'PSETEX', 'SETNX',
|
||||
'GEOADD', 'BITFIELD', 'SETBIT', 'HSETNX',
|
||||
'LINSERT', 'RPOPLPUSH', 'LMOVE',
|
||||
'SPOP', 'SMOVE', 'SDIFFSTORE', 'SINTERSTORE', 'SUNIONSTORE',
|
||||
'ZPOPMAX', 'ZPOPMIN', 'ZREMRANGEBYLEX',
|
||||
'XGROUP', 'XCLAIM', 'XSETID', 'SWAPDB',
|
||||
])
|
||||
|
||||
export function log(connection: string, command: string, duration: number): void {
|
||||
|
||||
@@ -43,7 +43,8 @@ export function detectFormat(value: string): FormatType {
|
||||
// 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))
|
||||
const sample = value.length > 4096 ? value.substring(0, 4096) : value
|
||||
const hasNonPrintable = /[\x00-\x08\x0e-\x1f]/.test(sample)
|
||||
if (hasNonPrintable) return 'hex'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ export async function scanKeys(
|
||||
count: number
|
||||
): Promise<{ cursor: string; keys: string[] }> {
|
||||
const client = requireClient(id)
|
||||
if (!pattern || pattern === '') pattern = '*'
|
||||
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count)
|
||||
return { cursor: nextCursor, keys }
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import electronUpdater from 'electron-updater'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let initialized = false
|
||||
|
||||
export function initUpdater(win: BrowserWindow): void {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
mainWindow = win
|
||||
const autoUpdater = electronUpdater.autoUpdater
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ function getLastState(): WinState {
|
||||
function saveState(win: BrowserWindow): void {
|
||||
const bounds = win.getBounds()
|
||||
const state = { ...bounds, maximized: win.isMaximized() }
|
||||
writeFile(STATE_FILE, JSON.stringify(state), 'utf-8', () => {})
|
||||
writeFile(STATE_FILE, JSON.stringify(state), 'utf-8', (err) => {
|
||||
if (err) console.error('Failed to save window state:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export function restoreAndTrack(win: BrowserWindow): void {
|
||||
|
||||
@@ -49,6 +49,7 @@ watch(() => props.visible, async (v) => {
|
||||
if (props.connection) {
|
||||
isEdit.value = true
|
||||
const decryptedAuth = await connStore.decryptPassword(props.connection.auth || '')
|
||||
if (!props.visible) return
|
||||
Object.assign(form, {
|
||||
name: props.connection.name,
|
||||
host: props.connection.host,
|
||||
|
||||
@@ -59,9 +59,7 @@ async function refresh() {
|
||||
const result = await window.electronAPI.redis.hashScan(connStore.activeId, keyStore.selectedKey, '0', 100)
|
||||
keyStore.keyData = result.fields
|
||||
await checkHexpireSupport()
|
||||
if (supportsHexpire.value) {
|
||||
await loadFieldTtls()
|
||||
}
|
||||
// TTL is loaded lazily on field focus instead of upfront
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -77,15 +75,20 @@ async function checkHexpireSupport() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFieldTtls() {
|
||||
async function loadFieldTtl(field: HashField) {
|
||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||
for (const field of fields.value) {
|
||||
try {
|
||||
const ttlResult = await window.electronAPI.redis.execute(connStore.activeId, 'HTTL', [keyStore.selectedKey, 'FIELDS', '1', field.field])
|
||||
field.ttl = Number(ttlResult)
|
||||
} catch {
|
||||
field.ttl = null
|
||||
}
|
||||
try {
|
||||
const ttlResult = await window.electronAPI.redis.execute(connStore.activeId, 'HTTL', [keyStore.selectedKey, 'FIELDS', '1', field.field])
|
||||
field.ttl = Number(ttlResult)
|
||||
} catch {
|
||||
field.ttl = null
|
||||
}
|
||||
}
|
||||
|
||||
function onFieldClick(row: HashField) {
|
||||
// Lazily load TTL for the clicked field if not already loaded
|
||||
if (supportsHexpire.value && row.ttl === null) {
|
||||
loadFieldTtl(row)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +151,7 @@ async function addField() {
|
||||
</div>
|
||||
|
||||
<div class="hash-table-wrap">
|
||||
<el-table :data="filteredFields" size="small" class="hash-table" height="100%">
|
||||
<el-table :data="filteredFields" size="small" class="hash-table" height="100%" @cell-click="onFieldClick">
|
||||
<el-table-column prop="field" :label="t('editor.field')" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.field" size="small" class="hash-input" />
|
||||
|
||||
@@ -85,7 +85,7 @@ async function save() {
|
||||
<div class="editor-content">
|
||||
<vue-monaco-editor
|
||||
v-model:value="editedValue"
|
||||
theme="vs-dark"
|
||||
:theme="monacoTheme"
|
||||
language="json"
|
||||
:options="editorOptions"
|
||||
height="100%"
|
||||
|
||||
@@ -145,16 +145,28 @@ async function exportKeys() {
|
||||
const keysArray = Array.from(selectedKeys.value)
|
||||
const rows: string[] = []
|
||||
let exported = 0
|
||||
const BATCH_SIZE = 20
|
||||
|
||||
for (const key of keysArray) {
|
||||
try {
|
||||
const dumpResult = await window.electronAPI.redis.execute(connStore.activeId, 'DUMP', [key])
|
||||
const ttl = await window.electronAPI.redis.getKeyTTL(connStore.activeId, key)
|
||||
const hexKey = strToHex(key)
|
||||
const hexValue = binaryToHex(dumpResult)
|
||||
rows.push(`${hexKey},${hexValue},${ttl}`)
|
||||
exported++
|
||||
} catch {
|
||||
for (let i = 0; i < keysArray.length; i += BATCH_SIZE) {
|
||||
const batch = keysArray.slice(i, i + BATCH_SIZE)
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map(key =>
|
||||
Promise.all([
|
||||
window.electronAPI.redis.execute(connStore.activeId, 'DUMP', [key]),
|
||||
window.electronAPI.redis.getKeyTTL(connStore.activeId, key)
|
||||
])
|
||||
)
|
||||
)
|
||||
for (let j = 0; j < batchResults.length; j++) {
|
||||
const result = batchResults[j]
|
||||
if (result.status === 'fulfilled') {
|
||||
const [dumpResult, ttl] = result.value
|
||||
const key = batch[j]
|
||||
const hexKey = strToHex(key)
|
||||
const hexValue = binaryToHex(dumpResult)
|
||||
rows.push(`${hexKey},${hexValue},${ttl}`)
|
||||
exported++
|
||||
}
|
||||
// Skip keys that fail to dump
|
||||
}
|
||||
}
|
||||
@@ -235,23 +247,12 @@ async function batchDelete() {
|
||||
if (!connStore.activeId || selectedKeys.value.size === 0) return
|
||||
const count = selectedKeys.value.size
|
||||
const keysArray = Array.from(selectedKeys.value)
|
||||
const previewKeys = keysArray.slice(0, 100)
|
||||
const remaining = count - 100
|
||||
|
||||
const keyListHtml = previewKeys.map(k => `<div class="batch-preview-key">${escapeHtml(formatKeyName(k))}</div>`).join('')
|
||||
const hintText = t('key.batchPreviewHint', { n: count })
|
||||
const andMore = remaining > 0 ? `<div class="batch-preview-more">${t('key.andMore', { n: remaining })}</div>` : ''
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`<div class="batch-preview">
|
||||
<div class="batch-preview-hint">${hintText}</div>
|
||||
<div class="batch-preview-list">${keyListHtml}</div>
|
||||
${andMore}
|
||||
</div>`,
|
||||
t('key.batchDeleteConfirm', { n: count }),
|
||||
t('key.batchPreview'),
|
||||
{
|
||||
dangerouslyUseHTMLString: true,
|
||||
confirmButtonText: t('key.batchDelete'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning',
|
||||
|
||||
@@ -170,7 +170,7 @@ async function execute() {
|
||||
'LPUSH', 'LPUSHX', 'RPUSH', 'RPUSHX', 'LSET', 'LINSERT', 'LREM', 'LPOP', 'RPOP',
|
||||
'SADD', 'SREM', 'SMOVE', 'SPOP', 'SINTERSTORE', 'SUNIONSTORE', 'SDIFFSTORE',
|
||||
'ZADD', 'ZREM', 'ZINCRBY', 'ZPOPMIN', 'ZPOPMAX', 'ZINTERSTORE', 'ZUNIONSTORE',
|
||||
'XADD', 'XDEL', 'XTRIM', 'XSETID', 'XGROUP', 'XGROUP', 'XGROUP',
|
||||
'XADD', 'XDEL', 'XTRIM', 'XSETID', 'XGROUP',
|
||||
'DEL', 'UNLINK', 'RENAME', 'RENAMENX',
|
||||
'EXPIRE', 'EXPIREAT', 'PEXPIRE', 'PEXPIREAT', 'PERSIST',
|
||||
'FLUSHDB', 'FLUSHALL', 'SWAPDB', 'MOVE', 'COPY',
|
||||
|
||||
@@ -238,46 +238,3 @@ defineExpose({ refresh })
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.slow-log {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cmd-text {
|
||||
font-family: 'Cascadia Code', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-footer {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -112,6 +112,7 @@ export default {
|
||||
batchConfirm: '{n} Schlüssel löschen?',
|
||||
batchDeleted: '{n} Schlüssel gelöscht',
|
||||
batchPreview: 'Löschvorschau',
|
||||
batchDeleteConfirm: '{n} Schlüssel löschen?',
|
||||
batchPreviewHint: '{n} Schlüssel werden gelöscht',
|
||||
andMore: '...und {n} weitere',
|
||||
selected: '{n} ausgewählt',
|
||||
|
||||
@@ -112,6 +112,7 @@ export default {
|
||||
batchConfirm: 'Delete {n} key(s)?',
|
||||
batchDeleted: 'Deleted {n} key(s)',
|
||||
batchPreview: 'Delete Preview',
|
||||
batchDeleteConfirm: 'Delete {n} key(s)?',
|
||||
batchPreviewHint: 'About to delete {n} keys',
|
||||
andMore: '...and {n} more',
|
||||
selected: '{n} selected',
|
||||
|
||||
@@ -112,6 +112,7 @@ export default {
|
||||
batchConfirm: '{n} 個のキーを削除しますか?',
|
||||
batchDeleted: '{n} 個のキーを削除しました',
|
||||
batchPreview: '削除プレビュー',
|
||||
batchDeleteConfirm: '{n} 個のキーを削除しますか?',
|
||||
batchPreviewHint: '{n} 個のキーを削除しようとしています',
|
||||
andMore: '...他 {n} 個',
|
||||
selected: '{n} 個選択済み',
|
||||
|
||||
@@ -112,6 +112,7 @@ export default {
|
||||
batchConfirm: '{n}개 키를 삭제하시겠습니까?',
|
||||
batchDeleted: '{n}개 키가 삭제되었습니다',
|
||||
batchPreview: '삭제 미리보기',
|
||||
batchDeleteConfirm: '{n}개 키를 삭제하시겠습니까?',
|
||||
batchPreviewHint: '{n}개 키를 삭제하려고 합니다',
|
||||
andMore: '...그 외 {n}개',
|
||||
selected: '{n}개 선택됨',
|
||||
|
||||
@@ -112,6 +112,7 @@ export default {
|
||||
batchConfirm: '删除 {n} 个键?',
|
||||
batchDeleted: '已删除 {n} 个键',
|
||||
batchPreview: '删除预览',
|
||||
batchDeleteConfirm: '确定删除 {n} 个键?',
|
||||
batchPreviewHint: '即将删除以下 {n} 个键',
|
||||
andMore: '...以及其他 {n} 个',
|
||||
selected: '已选 {n} 项',
|
||||
|
||||
@@ -113,8 +113,8 @@ export const useKeyStore = defineStore('key', () => {
|
||||
hasMore.value = result.cursor !== '0'
|
||||
|
||||
if (useTree.value) {
|
||||
const conn = (await window.electronAPI.storage.getConnections())
|
||||
.find((c: any) => c.id === connId)
|
||||
const connectionStore = useConnectionStore()
|
||||
const conn = connectionStore.connections.find(c => c.id === connId)
|
||||
const sep = conn?.separator || ':'
|
||||
treeKeys.value = buildTree(keys.value.map(k => k.name), sep)
|
||||
}
|
||||
@@ -149,8 +149,8 @@ export const useKeyStore = defineStore('key', () => {
|
||||
keys.value = keys.value.filter(k => k.name !== key)
|
||||
// Rebuild tree if needed
|
||||
if (useTree.value) {
|
||||
const conn = (await window.electronAPI.storage.getConnections())
|
||||
.find((c: any) => c.id === connId)
|
||||
const connectionStore = useConnectionStore()
|
||||
const conn = connectionStore.connections.find(c => c.id === connId)
|
||||
const sep = conn?.separator || ':'
|
||||
treeKeys.value = buildTree(keys.value.map(k => k.name), sep)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user