feat(phase7): pattern search, health check, keyboard shortcuts

- KeyList: pattern input for Redis MATCH, local filter, Go button
- ConnectionStore: health check interval (5s ping), healthOk state
- StatusBar: health indicator (unstable = yellow pulse)
- RedisService: ping, isConnected methods
- Sidebar: Delete key (Del), deselect (Esc), refresh (F5)
- useKeyboard: expanded shortcut map
This commit is contained in:
2026-07-04 19:39:22 +08:00
parent 0ac0436cfd
commit 7c0cce3e64
9 changed files with 147 additions and 17 deletions
+6
View File
@@ -207,4 +207,10 @@ export function registerIpcHandlers(): void {
ipcMain.handle('redis:existsKey', async (_e, id: string, key: string) => {
return redisService.existsKey(id, key)
})
ipcMain.handle('redis:ping', async (_e, id: string) => {
return redisService.ping(id)
})
ipcMain.handle('redis:isConnected', (_e, id: string) => {
return redisService.isConnected(id)
})
}
+11
View File
@@ -337,3 +337,14 @@ export async function existsKey(id: string, key: string): Promise<boolean> {
if (!client) throw new Error(`Client ${id} not found`)
return (await client.exists(key)) === 1
}
export async function ping(id: string): Promise<string> {
const client = clients.get(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.ping()
}
export function isConnected(id: string): boolean {
const client = clients.get(id)
return client?.status === 'ready'
}
+2
View File
@@ -94,6 +94,8 @@ export interface ElectronAPI {
streamDel: (id: string, key: string, ids: string[]) => Promise<number>
renameKey: (id: string, oldKey: string, newKey: string) => Promise<void>
existsKey: (id: string, key: string) => Promise<boolean>
ping: (id: string) => Promise<string>
isConnected: (id: string) => Promise<boolean>
}
}
+4
View File
@@ -110,6 +110,10 @@ const electronAPI = {
ipcRenderer.invoke('redis:renameKey', id, oldKey, newKey),
existsKey: (id: string, key: string): Promise<boolean> =>
ipcRenderer.invoke('redis:existsKey', id, key),
ping: (id: string): Promise<string> =>
ipcRenderer.invoke('redis:ping', id),
isConnected: (id: string): Promise<boolean> =>
ipcRenderer.invoke('redis:isConnected', id),
},
}
+58 -7
View File
@@ -9,10 +9,18 @@ const keyStore = useKeyStore()
const connStore = useConnectionStore()
const filterText = ref('')
const patternInput = ref('*')
const showTree = ref(false)
const multiSelect = ref(false)
const selectedKeys = ref<Set<string>>(new Set())
function applyPattern() {
keyStore.pattern = patternInput.value || '*'
if (connStore.activeId) {
keyStore.scanKeys(connStore.activeId, '0', true)
}
}
async function loadMore() {
if (!connStore.activeId) return
await keyStore.scanKeys(connStore.activeId, keyStore.scanCursor)
@@ -21,6 +29,7 @@ async function loadMore() {
async function refreshKeys() {
if (!connStore.activeId) return
selectedKeys.value.clear()
keyStore.pattern = patternInput.value || '*'
await keyStore.scanKeys(connStore.activeId, '0', true)
}
@@ -59,6 +68,12 @@ function toggleMultiSelect() {
}
}
function deselectAll() {
selectedKeys.value.clear()
keyStore.selectedKey = null
keyStore.keyData = null
}
const filteredKeys = ref<string[]>([])
watch(
() => keyStore.keys,
@@ -75,17 +90,34 @@ watch(filterText, (val) => {
? keyStore.keys.filter(k => k.name.includes(val)).map(k => k.name)
: keyStore.keys.map(k => k.name)
})
defineExpose({ deselectAll })
</script>
<template>
<div class="key-list">
<div class="key-pattern-row">
<el-input
v-model="patternInput"
size="small"
placeholder="Key pattern (e.g. user:*)"
class="pattern-input"
@keyup.enter="applyPattern"
>
<template #prepend>
<span class="pattern-label">MATCH</span>
</template>
</el-input>
<el-button size="small" type="primary" @click="applyPattern">Go</el-button>
</div>
<div class="key-toolbar">
<el-input
v-model="filterText"
placeholder="Filter keys..."
placeholder="Local filter..."
size="small"
clearable
prefix-icon="Search"
class="filter-input"
/>
<el-button-group class="key-actions">
<el-button size="small" @click="refreshKeys" :disabled="!connStore.activeId">
@@ -116,7 +148,6 @@ watch(filterText, (val) => {
</div>
<div class="key-content">
<!-- Flat list -->
<div v-if="!showTree" class="key-flat-list">
<div
v-for="keyName in filteredKeys"
@@ -140,7 +171,6 @@ watch(filterText, (val) => {
</div>
</div>
<!-- Tree view -->
<div v-else class="key-tree">
<div
v-for="keyName in filteredKeys"
@@ -189,14 +219,35 @@ watch(filterText, (val) => {
background: var(--bg-secondary);
}
.key-toolbar {
padding: 12px;
.key-pattern-row {
padding: 10px 12px 6px;
display: flex;
gap: 8px;
gap: 6px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.pattern-input {
flex: 1;
}
.pattern-label {
font-size: 10px;
font-weight: 700;
color: var(--accent-light);
}
.key-toolbar {
padding: 6px 12px 10px;
display: flex;
gap: 8px;
flex-shrink: 0;
}
.filter-input {
flex: 1;
}
.key-actions {
flex-shrink: 0;
}
+28 -5
View File
@@ -7,6 +7,7 @@ import { useConnectionStore } from '@renderer/stores/connection'
import { useKeyStore } from '@renderer/stores/key'
import type { ConnectionConfig } from '@renderer/stores/connection'
import { useKeyboard } from '@renderer/composables/useKeyboard'
import { ElMessage, ElMessageBox } from 'element-plus'
const connStore = useConnectionStore()
const keyStore = useKeyStore()
@@ -23,13 +24,35 @@ function handleNew() {
dialogVisible.value = true
}
async function deleteSelectedKey() {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await ElMessageBox.confirm(`Delete key "${keyStore.selectedKey}"?`, 'Confirm')
await window.electronAPI.redis.deleteKey(connStore.activeId, keyStore.selectedKey)
ElMessage.success('Deleted')
keyStore.selectedKey = null
keyStore.keyData = null
await keyStore.scanKeys(connStore.activeId, '0', true)
} catch { /* cancelled */ }
}
function deselectKey() {
keyStore.selectedKey = null
keyStore.keyData = null
}
function refreshAll() {
if (connStore.activeId) {
keyStore.scanKeys(connStore.activeId, '0', true)
}
}
useKeyboard({
'ctrl+n': handleNew,
'ctrl+r': () => {
if (connStore.activeId) {
keyStore.scanKeys(connStore.activeId, '0', true)
}
},
'ctrl+r': refreshAll,
'f5': refreshAll,
'delete': deleteSelectedKey,
'escape': deselectKey,
})
</script>
+8 -3
View File
@@ -2,11 +2,9 @@
// src/renderer/src/components/StatusBar.vue
import { computed } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
import { useKeyStore } from '@renderer/stores/key'
import DbSelector from './DbSelector.vue'
const connStore = useConnectionStore()
const keyStore = useKeyStore()
const statusText = computed(() => {
if (connStore.connecting) return 'Connecting...'
@@ -20,7 +18,8 @@ const statusText = computed(() => {
const statusClass = computed(() => {
if (connStore.connecting) return 'status-connecting'
if (connStore.error) return 'status-error'
if (connStore.isConnected) return 'status-connected'
if (connStore.isConnected && connStore.healthOk) return 'status-connected'
if (connStore.isConnected && !connStore.healthOk) return 'status-unstable'
return 'status-disconnected'
})
</script>
@@ -88,6 +87,12 @@ const statusClass = computed(() => {
box-shadow: 0 0 6px var(--danger);
}
.status-dot.status-unstable {
background: var(--warning);
box-shadow: 0 0 6px var(--warning);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
+29 -1
View File
@@ -33,6 +33,8 @@ export const useConnectionStore = defineStore('connection', () => {
const connecting = ref(false)
const error = ref<string | null>(null)
const serverInfo = ref<string | null>(null)
const healthOk = ref(true)
let healthTimer: ReturnType<typeof setInterval> | null = null
const activeConnection = computed(() =>
connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null
@@ -77,6 +79,7 @@ export const useConnectionStore = defineStore('connection', () => {
if (result.success) {
activeId.value = conn.id
serverInfo.value = await window.electronAPI.redis.getInfo(conn.id)
startHealthCheck(conn.id)
} else {
error.value = result.error || 'Connection failed'
}
@@ -88,6 +91,7 @@ export const useConnectionStore = defineStore('connection', () => {
}
async function disconnect() {
stopHealthCheck()
if (activeId.value) {
await window.electronAPI.redis.disconnect(activeId.value)
activeId.value = null
@@ -95,6 +99,30 @@ export const useConnectionStore = defineStore('connection', () => {
}
}
function startHealthCheck(connId: string) {
stopHealthCheck()
healthOk.value = true
healthTimer = setInterval(async () => {
try {
const ok = await window.electronAPI.redis.isConnected(connId)
healthOk.value = ok
if (!ok && activeId.value === connId) {
// Connection lost
error.value = 'Connection lost'
}
} catch {
healthOk.value = false
}
}, 5000)
}
function stopHealthCheck() {
if (healthTimer) {
clearInterval(healthTimer)
healthTimer = null
}
}
async function testConnection(opts: {
host: string; port: number; auth?: string; username?: string; tls?: boolean
}): Promise<{ success: boolean; error?: string }> {
@@ -137,7 +165,7 @@ export const useConnectionStore = defineStore('connection', () => {
}
return {
connections, activeId, connecting, error, serverInfo,
connections, activeId, connecting, error, serverInfo, healthOk,
activeConnection, isConnected,
loadConnections, saveConnection, deleteConnection, reorderConnections,
connect, disconnect, testConnection, exportConnections, importConnections,
File diff suppressed because one or more lines are too long