diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index 37adfae9d..ba9d2ad06 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -3,7 +3,7 @@ import { computed, ref, nextTick, onBeforeUnmount, onMounted } from "vue"; import { useI18n } from "vue-i18n"; import { onClickOutside } from "@vueuse/core"; import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller"; -import { Braces, Copy, Eye, FileText, Terminal, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, IndentIncrease, IndentDecrease, ArrowUp, ArrowDown, ArrowUpDown } from "@lucide/vue"; +import { Braces, Copy, Eye, FileText, Terminal, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, IndentIncrease, IndentDecrease, ArrowUp, ArrowDown, ArrowUpDown, Search } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -78,6 +78,9 @@ const redisJsonWordWrap = ref(readRedisJsonWordWrap()); const redisJsonHighlighter = ref(); const hashSortBy = ref<"field" | "value" | null>(null); const hashSortDir = ref<"asc" | "desc">("asc"); +const hashSearchQuery = ref(""); +const activeHashSearchQuery = ref(""); +const searchLoading = ref(false); function toggleHashSort(column: "field" | "value") { if (hashSortBy.value === column && hashSortDir.value === "desc") { @@ -111,6 +114,55 @@ const hashCollectionRows = computed(() => })), ); +function redisGlobEscape(s: string): string { + return s.replace(/[*?[\]\\]/g, "\\$&"); +} + +function hashSearchPattern(query: string): string | undefined { + return query ? `*${redisGlobEscape(query)}*` : undefined; +} + +let hashSearchTimer: ReturnType | null = null; +let hashSearchRequestId = 0; + +function onHashSearchInput() { + if (hashSearchTimer) clearTimeout(hashSearchTimer); + hashSearchTimer = setTimeout(() => void onHashSearch(), 400); +} + +function onHashSearchKeydown(event: KeyboardEvent) { + if (event.key === "Enter") { + if (hashSearchTimer) clearTimeout(hashSearchTimer); + hashSearchTimer = null; + void onHashSearch(); + return; + } + if (event.key === "Escape") { + if (hashSearchTimer) clearTimeout(hashSearchTimer); + hashSearchTimer = null; + hashSearchQuery.value = ""; + void onHashSearch(); + } +} + +async function onHashSearch() { + const query = hashSearchQuery.value.trim(); + if (!data.value) return; + const requestId = ++hashSearchRequestId; + searchLoading.value = true; + try { + const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "hash", 0, 200, hashSearchPattern(query)); + if (requestId !== hashSearchRequestId) return; + const items = Array.isArray(result.value) ? result.value : []; + activeHashSearchQuery.value = query; + collectionItems.value = items; + scanCursor.value = result.scan_cursor ?? undefined; + clearSelectedMember(); + } finally { + if (requestId === hashSearchRequestId) searchLoading.value = false; + } +} + const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value)); const selectedMemberJsonDetail = computed(() => selectedMemberDetail.value.json ?? null); const stringValueDetail = computed(() => (data.value?.key_type === "string" ? formatRedisMemberDetail(data.value.value) : null)); @@ -241,6 +293,12 @@ function collectionCountLabel(kind: "items" | "fields" | "members", loaded: numb async function load(options: { selectDefaultMember?: boolean } = {}) { const shouldSelectDefaultMember = options.selectDefaultMember ?? true; + if (hashSearchTimer) clearTimeout(hashSearchTimer); + hashSearchTimer = null; + hashSearchRequestId++; + hashSearchQuery.value = ""; + activeHashSearchQuery.value = ""; + searchLoading.value = false; loading.value = true; try { const loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw); @@ -266,10 +324,14 @@ async function load(options: { selectDefaultMember?: boolean } = {}) { } async function loadMore() { - if (!data.value || !hasMore.value || loadingMore.value) return; + if (!data.value || !hasMore.value || loadingMore.value || (data.value.key_type === "hash" && searchLoading.value)) return; + const keyType = data.value.key_type; + const hashFilter = keyType === "hash" ? hashSearchPattern(activeHashSearchQuery.value) : undefined; + const requestId = hashSearchRequestId; loadingMore.value = true; try { - const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, data.value.key_type, scanCursor.value!, 200); + const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, hashFilter); + if (keyType === "hash" && requestId !== hashSearchRequestId) return; const newItems = Array.isArray(result.value) ? result.value : []; collectionItems.value = [...collectionItems.value, ...newItems]; scanCursor.value = result.scan_cursor ?? undefined; @@ -803,6 +865,7 @@ onBeforeUnmount(() => { stopResizeMemberSheet(); stopResizeHashColumns(); stopResizeZsetColumns(); + if (hashSearchTimer) clearTimeout(hashSearchTimer); }); @@ -969,7 +1032,11 @@ onBeforeUnmount(() => {
- {{ collectionCountLabel("fields", collectionItems.length, data.total) }} + {{ collectionCountLabel("fields", collectionItems.length, activeHashSearchQuery ? null : data.total) }} +
+ + +
@@ -1028,7 +1095,7 @@ onBeforeUnmount(() => {