diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 472b66ccc..9d258955a 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -26,7 +26,7 @@ import { buildRedisKeyTree, collectExpandedGroupIds, collectRedisGroupKeyRaws, f import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety"; import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable"; import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession"; -import { formatRedisConsoleValue, formatRedisStringValue } from "@/lib/redis/redisValuePresentation"; +import { formatRedisConsoleValue, redisValuePreview, redisValueSize } from "@/lib/redis/redisValuePresentation"; import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts"; import { copyToClipboard } from "@/lib/common/clipboard"; import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle"; @@ -388,10 +388,10 @@ function redisValueToKeyInfo(value: RedisValue): RedisKeyInfo { return { key_display: value.key_display, key_raw: value.key_raw, - key_type: value.key_type, + key_type: value.redis_type, ttl: value.ttl, - size: typeof value.value === "string" ? value.value.length : (value.total ?? 0), - value_preview: createdKeyPreview(value.value), + size: redisValueSize(value), + value_preview: redisValuePreview(value), }; } @@ -642,23 +642,14 @@ function openCreateKeyDialog() { showCreateKeyDialog.value = true; } -function createdKeyPreview(value: any): string { - if (typeof value === "string") { - const text = formatRedisStringValue(value).replace(/\s+/g, " ").trim(); - return text.length > 160 ? `${text.slice(0, 160)}…` : text; - } - if (Array.isArray(value) && value.length > 0) return String(value.length); - return ""; -} - -function upsertCreatedKey(value: any) { +function upsertCreatedKey(value: RedisValue) { const keyInfo: RedisKeyInfo = { key_display: value.key_display, key_raw: value.key_raw, - key_type: value.key_type, + key_type: value.redis_type, ttl: value.ttl, - size: typeof value.value === "string" ? value.value.length : (value.total ?? 0), - value_preview: createdKeyPreview(value.value), + size: redisValueSize(value), + value_preview: redisValuePreview(value), }; const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw); if (existingIndex >= 0) { diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index 9edc59894..c82967646 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, Search } from "@lucide/vue"; +import { Copy, Eye, 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"; @@ -12,15 +12,33 @@ import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/com import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import RedisJsonTree from "./RedisJsonTree.vue"; import * as api from "@/lib/backend/api"; -import type { RedisKeyInfo, RedisValue } from "@/lib/backend/api"; +import type { RedisBlob, RedisHashItem, RedisKeyInfo, RedisListItem, RedisSetItem, RedisStreamEntry, RedisValue, RedisZsetItem } from "@/lib/backend/api"; import { useToast } from "@/composables/useToast"; import { useTheme } from "@/composables/useTheme"; import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle"; import { createRedisShikiJsonHighlighter, type RedisJsonHighlighter } from "@/lib/redis/redisJsonHighlighter"; import { copyToClipboard } from "@/lib/common/clipboard"; import { formatTtl } from "@/lib/common/ttlFormat"; +import { + canRenderRedisValueFormat, + canEditRedisMemberDetail, + clampRedisMemberDetailSheetWidth, + formatRedisMemberDetail, + getRedisMemberSelectionKey, + isRedisBlob, + preferredRedisValueFormat, + REDIS_VALUE_FORMAT_DISPLAY_ORDER, + redisBlobText, + redisCollectionPageItems, + redisMemberCopyText, + redisValueCopyText, + redisValueCollectionItems, + redisValueCollectionScanCursor, + redisValueCollectionTotal, + type RedisCollectionItem, + type RedisValueFormat, +} from "@/lib/redis/redisValuePresentation"; import { safeJsonFormat } from "@/lib/common/safeJsonFormat"; -import { canEditRedisMemberDetail, clampRedisMemberDetailSheetWidth, formatRedisMemberDetail, getRedisMemberSelectionKey } from "@/lib/redis/redisValuePresentation"; const { t } = useI18n(); const { toast } = useToast(); @@ -37,6 +55,11 @@ const props = defineProps<{ const emit = defineEmits<{ deleted: []; loaded: [value: RedisValue] }>(); +const REDIS_JSON_WRAP_STORAGE_KEY = "dbx-redis-json-word-wrap"; +const REDIS_VALUE_FORMAT_STORAGE_KEY = "dbx-redis-value-format"; +const REDIS_COLLECTION_ROW_HEIGHT = 32; +const REDIS_STREAM_MIN_ROW_HEIGHT = 96; + const data = ref(null); const loading = ref(false); const loadingMore = ref(false); @@ -54,7 +77,7 @@ const editTtlWrapper = ref(); onClickOutside(editTtlWrapper, () => { if (editingTtl.value) cancelEditTtl(); }); -const collectionItems = ref([]); +const collectionItems = ref([]); const scanCursor = ref(undefined); const selectedMemberTitle = ref(""); const selectedMemberRaw = ref(""); @@ -71,10 +94,9 @@ const isResizingHashColumns = ref(false); const zsetTableRef = ref(null); const zsetScoreWidth = ref(220); const isResizingZsetColumns = ref(false); -type RedisValueView = "json" | "raw"; -const REDIS_JSON_WRAP_STORAGE_KEY = "dbx-redis-json-word-wrap"; -const stringValueView = ref("raw"); -const memberValueView = ref("raw"); +const stringValueView = ref(readPreferredRedisValueFormat()); +const memberValueView = ref(readPreferredRedisValueFormat()); +const redisJsonView = ref<"raw" | "tree">("raw"); const redisJsonWordWrap = ref(readRedisJsonWordWrap()); const redisJsonHighlighter = ref(); const hashSortBy = ref<"field" | "value" | null>(null); @@ -94,29 +116,133 @@ function toggleHashSort(column: "field" | "value") { } } -const sortedHashItems = computed(() => { - if (!hashSortBy.value) return collectionItems.value; - const items = [...collectionItems.value]; +const redisKind = computed(() => data.value?.data.kind ?? "unknown"); +const isStringLikeKind = computed(() => redisKind.value === "string"); +const stringBlob = computed(() => { + const value = data.value; + if (!value) return null; + return value.data.kind === "string" ? value.data.content : null; +}); +const stringValueDetail = computed(() => (stringBlob.value ? formatRedisMemberDetail(stringBlob.value, { allowJsonText: true }) : null)); +const redisJsonValue = computed(() => (data.value?.data.kind === "json" ? data.value.data.value : null)); +const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value, { allowJsonText: true })); +const redisJsonAppearance = computed(() => (isDark.value ? "dark" : "light")); +const isBinaryStringValue = computed(() => Boolean(stringValueDetail.value?.binary)); +const selectedMemberCanEdit = computed(() => selectedMemberContext.value?.canEdit ?? false); +const canEditCurrentStringFormat = computed(() => Boolean(stringValueDetail.value?.editable) && stringValueView.value === "utf8"); +const canEditCurrentMemberFormat = computed(() => selectedMemberCanEdit.value && memberValueView.value === "utf8"); +const hasMore = computed(() => scanCursor.value != null && scanCursor.value > 0); +const collectionTotal = computed(() => (data.value ? redisValueCollectionTotal(data.value) : null)); +const hashGridStyle = computed(() => ({ + gridTemplateColumns: `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`, +})); +const zsetGridStyle = computed(() => ({ + gridTemplateColumns: `${zsetScoreWidth.value}px minmax(0, 1fr) 84px`, +})); +const metadataSizeLabel = computed(() => { + const metadata = props.metadata; + const size = metadata?.size ?? 0; + if (!metadata || size <= 0) return ""; + if (metadata.key_type === "string") { + if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${size} B`; + } + return String(size); +}); +const streamRows = computed(() => { + if (data.value?.data.kind !== "stream") return []; + return data.value.data.entries.map((entry, index) => ({ + id: `${index}:${entry.id}`, + index, + entry, + })); +}); + +const sortedHashItems = computed(() => { + if (redisKind.value !== "hash") return []; + const items = [...(collectionItems.value as RedisHashItem[])]; + if (!hashSortBy.value) return items; const multiplier = hashSortDir.value === "asc" ? 1 : -1; const key = hashSortBy.value; items.sort((a, b) => { - const av = String(a[key] ?? ""); - const bv = String(b[key] ?? ""); + const av = key === "field" ? formatRedisMemberDetail(a.field).rawText : formatRedisMemberDetail(a.value).rawText; + const bv = key === "field" ? formatRedisMemberDetail(b.field).rawText : formatRedisMemberDetail(b.value).rawText; return av.localeCompare(bv) * multiplier; }); return items; }); -const hashCollectionRows = computed(() => +const hashCollectionRows = computed[]>(() => sortedHashItems.value.map((value, index) => ({ - id: `hash-sorted-${index}`, + id: `hash-${index}`, index, value, })), ); +const listRows = computed[]>(() => + redisKind.value === "list" + ? (collectionItems.value as RedisListItem[]).map((value, index) => ({ + id: collectionRowId(value, index), + index, + value, + })) + : [], +); +const setRows = computed[]>(() => + redisKind.value === "set" + ? (collectionItems.value as RedisSetItem[]).map((value, index) => ({ + id: collectionRowId(value, index), + index, + value, + })) + : [], +); +const zsetRows = computed[]>(() => + redisKind.value === "zset" + ? (collectionItems.value as RedisZsetItem[]).map((value, index) => ({ + id: collectionRowId(value, index), + index, + value, + })) + : [], +); + let hashSearchTimer: ReturnType | null = null; let hashSearchRequestId = 0; +let memberSheetResizeStartX = 0; +let memberSheetResizeStartWidth = 0; +let hashResizeStartX = 0; +let hashResizeStartWidth = 0; +let zsetResizeStartX = 0; +let zsetResizeStartWidth = 0; + +type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string }; +const pendingDelete = ref(null); + +type RedisMemberContext = + | { kind: "list"; index: number; canEdit: boolean } + | { kind: "set"; member: string | null; canEdit: boolean } + | { kind: "hash"; field: string | null; canEdit: boolean } + | { kind: "zset"; member: string | null; score: string; canEdit: boolean } + | { kind: "stream"; field: string; canEdit: false }; + +type RedisCollectionRow = { + id: string; + index: number; + value: T; +}; + +type RedisStreamRow = { + id: string; + index: number; + entry: RedisStreamEntry; +}; + +function collectionCountLabel(kind: "items" | "fields" | "members", loaded: number, total?: number | null) { + if (total == null || total === loaded) return t(`redis.${kind}`, { count: loaded }); + return t(`redis.loaded${kind[0].toUpperCase()}${kind.slice(1)}`, { loaded, total }); +} function onHashSearchInput() { if (hashSearchTimer) clearTimeout(hashSearchTimer); @@ -140,15 +266,14 @@ function onHashSearchKeydown(event: KeyboardEvent) { async function onHashSearch() { const query = hashSearchQuery.value.trim(); - if (!data.value) return; + if (redisKind.value !== "hash") return; const requestId = ++hashSearchRequestId; searchLoading.value = true; try { const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "hash", 0, 200, query || undefined); - if (requestId !== hashSearchRequestId) return; - const items = Array.isArray(result.value) ? result.value : []; + if (requestId !== hashSearchRequestId || result.kind !== "hash") return; activeHashSearchQuery.value = query; - collectionItems.value = items; + collectionItems.value = result.items; scanCursor.value = result.scan_cursor ?? undefined; clearSelectedMember(); } finally { @@ -156,75 +281,6 @@ async function onHashSearch() { } } -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)); -const stringJsonDetail = computed(() => stringValueDetail.value?.json ?? null); -const redisJsonAppearance = computed(() => (isDark.value ? "dark" : "light")); -const memberRawJsonHtml = computed(() => (selectedMemberJsonDetail.value ? highlightRedisJson(selectedMemberJsonDetail.value.rawText) : "")); -const hashGridStyle = computed(() => ({ - gridTemplateColumns: `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`, -})); -const zsetGridStyle = computed(() => ({ - gridTemplateColumns: `${zsetScoreWidth.value}px minmax(0, 1fr) 84px`, -})); -const selectedMemberCanEdit = computed(() => selectedMemberContext.value != null && canEditRedisMemberDetail(selectedMemberContext.value.kind)); -const REDIS_COLLECTION_ROW_HEIGHT = 32; -const REDIS_STREAM_MIN_ROW_HEIGHT = 96; - -type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string }; - -const pendingDelete = ref(null); - -let memberSheetResizeStartX = 0; -let memberSheetResizeStartWidth = 0; -let hashResizeStartX = 0; -let hashResizeStartWidth = 0; -let zsetResizeStartX = 0; -let zsetResizeStartWidth = 0; - -type RedisMemberContext = { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "hash"; field: string } | { kind: "zset"; member: string; score: number } | { kind: "stream"; field: string }; - -type RedisCollectionRow = { - id: string; - index: number; - value: any; -}; - -type RedisStreamRow = { - id: string; - index: number; - entry: { - id?: string | number; - fields?: Record; - }; -}; - -const collectionRows = computed(() => - collectionItems.value.map((value, index) => ({ - id: `${index}`, - index, - value, - })), -); - -const streamRows = computed(() => { - if (!data.value || data.value.key_type !== "stream" || !Array.isArray(data.value.value)) return []; - return data.value.value.map((entry, index) => ({ - id: `${index}:${String(entry?.id ?? "")}`, - index, - entry, - })); -}); - -function streamFields(entry: RedisStreamRow["entry"]): [string, unknown][] { - return Object.entries(entry.fields ?? {}); -} - -function streamFieldCount(row: RedisStreamRow): number { - return streamFields(row.entry).length; -} - function readRedisJsonWordWrap(): boolean { try { return localStorage.getItem(REDIS_JSON_WRAP_STORAGE_KEY) !== "false"; @@ -242,107 +298,13 @@ function setRedisJsonWordWrap(value: boolean) { } } -function rawRedisValueText(value: unknown): string { - if (typeof value === "string") return value; - return String(value ?? ""); -} - -function highlightRedisJson(json: string): string { - return redisJsonHighlighter.value?.(json, redisJsonAppearance.value) ?? escapeHtml(json); -} - -function escapeHtml(value: string): string { - return value.replace(/&/g, "&").replace(//g, ">"); -} - -const deleteDetails = computed(() => { - const pending = pendingDelete.value; - if (!pending) return ""; - const key = formatValue(props.keyDisplay); - if (pending.kind === "key") return t("dangerDialog.redisKeyDetails", { key }); - if (pending.kind === "hash") return t("dangerDialog.redisHashFieldDetails", { key, field: formatValue(pending.field) }); - if (pending.kind === "list") return t("dangerDialog.redisListItemDetails", { key, index: pending.index }); - if (pending.kind === "zset") return t("dangerDialog.redisSetMemberDetails", { key, member: formatValue(pending.member) }); - return t("dangerDialog.redisSetMemberDetails", { key, member: formatValue(pending.member) }); -}); - -const isBinaryStringValue = computed(() => data.value?.key_type === "string" && data.value?.value_is_binary); -const hasMore = computed(() => scanCursor.value != null && scanCursor.value > 0); -const metadataSizeLabel = computed(() => { - const metadata = props.metadata; - const size = metadata?.size ?? 0; - if (!metadata || size <= 0) return ""; - if (metadata.key_type === "string") { - if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${size} B`; - } - return String(size); -}); - -function collectionCountLabel(kind: "items" | "fields" | "members", loaded: number, total?: number | null) { - if (total == null || total === loaded) return t(`redis.${kind}`, { count: loaded }); - return t(`redis.loaded${kind[0].toUpperCase()}${kind.slice(1)}`, { loaded, total }); -} - -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; +function readPreferredRedisValueFormat(): RedisValueFormat { try { - const loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw); - data.value = loadedValue; - emit("loaded", loadedValue); - scanCursor.value = data.value.scan_cursor ?? undefined; - if (data.value.key_type === "string") { - const detail = formatRedisMemberDetail(data.value.value); - editValue.value = detail.rawText; - stringValueView.value = detail.format === "json" ? "json" : "raw"; - clearSelectedMember(); - } else if (["list", "set", "zset", "hash"].includes(data.value.key_type)) { - collectionItems.value = Array.isArray(data.value.value) ? [...data.value.value] : []; - if (shouldSelectDefaultMember) selectDefaultMember(data.value); - } else if (data.value.key_type === "stream") { - if (shouldSelectDefaultMember) selectDefaultMember(data.value); - } else { - clearSelectedMember(); - } - } finally { - loading.value = false; - } -} - -async function loadMore() { - 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" ? activeHashSearchQuery.value || undefined : undefined; - const requestId = hashSearchRequestId; - loadingMore.value = true; - try { - 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; - } finally { - loadingMore.value = false; - } -} - -async function saveString() { - if (isBinaryStringValue.value) return; - await api.redisSetString(props.connectionId, props.db, props.keyRaw, editValue.value); - isEditing.value = false; - await load(); -} - -function handleStringInput() { - if (!isBinaryStringValue.value) { - isEditing.value = true; + const stored = localStorage.getItem(REDIS_VALUE_FORMAT_STORAGE_KEY); + if (stored === "raw") return "utf8"; + return stored === "utf8" || stored === "ascii" || stored === "binary" || stored === "json" || stored === "javaserialize" || stored === "hex" || stored === "base64" ? stored : "utf8"; + } catch { + return "utf8"; } } @@ -362,7 +324,185 @@ function compressJsonText(raw: string): string | null { } } -function handleFormatStringJson() { +function rememberRedisValueFormat(format: RedisValueFormat) { + try { + localStorage.setItem(REDIS_VALUE_FORMAT_STORAGE_KEY, format); + } catch { + // Ignore storage failures; the toggle still works for the current session. + } +} + +function setStringValueFormat(format: RedisValueFormat) { + stringValueView.value = format; + if (stringValueDetail.value && canRenderRedisValueFormat(stringValueDetail.value, format)) { + rememberRedisValueFormat(format); + } +} + +function setMemberValueFormat(format: RedisValueFormat) { + memberValueView.value = format; + if (canRenderRedisValueFormat(selectedMemberDetail.value, format)) { + rememberRedisValueFormat(format); + } +} + +function redisFormatLabel(format: RedisValueFormat, rawLabel?: string): string { + switch (format) { + case "utf8": + return "UTF-8"; + case "ascii": + return "ASCII"; + case "binary": + return "Binary"; + case "json": + return t("redis.jsonView"); + case "javaserialize": + return "Java Serialized"; + case "hex": + return t("grid.hexViewerHex"); + case "base64": + return "Base64"; + default: + return rawLabel || t("redis.rawContent"); + } +} + +function isTextRedisFormat(format: RedisValueFormat): boolean { + return format === "utf8" || format === "ascii" || format === "binary"; +} + +function highlightRedisJson(json: string): string { + return redisJsonHighlighter.value?.(json, redisJsonAppearance.value) ?? escapeHtml(json); +} + +function escapeHtml(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">"); +} + +function detailTextForFormat(detail: ReturnType, format: RedisValueFormat): string { + switch (format) { + case "utf8": + return detail.utf8Text; + case "ascii": + return detail.asciiText; + case "binary": + return detail.binaryText; + case "json": + return detail.json?.formattedText ?? detail.rawText; + case "javaserialize": + return detail.javaSerialized?.formattedText ?? detail.rawText; + case "hex": + return detail.hexRows.map((row) => row.hex).join("\n"); + case "base64": + return detail.base64Text; + default: + return detail.rawText; + } +} + +function detailTextClass(format: RedisValueFormat): string { + if (!redisJsonWordWrap.value) return "whitespace-pre"; + return format === "ascii" || format === "binary" ? "whitespace-pre-wrap break-all" : "whitespace-pre-wrap break-words"; +} + +function rawRedisValueText(value: unknown): string { + return formatRedisMemberDetail(value).rawText; +} + +const deleteDetails = computed(() => { + const pending = pendingDelete.value; + if (!pending) return ""; + const key = formatValue(props.keyDisplay); + if (pending.kind === "key") return t("dangerDialog.redisKeyDetails", { key }); + if (pending.kind === "hash") return t("dangerDialog.redisHashFieldDetails", { key, field: formatValue(pending.field) }); + if (pending.kind === "list") return t("dangerDialog.redisListItemDetails", { key, index: pending.index }); + if (pending.kind === "zset") return t("dangerDialog.redisSetMemberDetails", { key, member: formatValue(pending.member) }); + return t("dangerDialog.redisSetMemberDetails", { key, member: formatValue(pending.member) }); +}); + +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); + data.value = loadedValue; + emit("loaded", loadedValue); + scanCursor.value = redisValueCollectionScanCursor(loadedValue); + collectionItems.value = redisValueCollectionItems(loadedValue); + isEditing.value = false; + + if (loadedValue.data.kind === "string") { + const detail = formatRedisMemberDetail(loadedValue.data.content, { allowJsonText: true }); + editValue.value = detail.rawText; + stringValueView.value = preferredRedisValueFormat(loadedValue.data.content, readPreferredRedisValueFormat(), { allowJsonText: true }); + clearSelectedMember(); + } else if (loadedValue.data.kind === "json") { + editValue.value = JSON.stringify(loadedValue.data.value, null, 2); + redisJsonView.value = "raw"; + clearSelectedMember(); + } else if (loadedValue.data.kind === "stream") { + if (shouldSelectDefaultMember) selectDefaultMember(loadedValue); + } else if (["list", "set", "hash", "zset"].includes(loadedValue.data.kind)) { + if (shouldSelectDefaultMember) selectDefaultMember(loadedValue); + } else { + clearSelectedMember(); + } + } finally { + loading.value = false; + } +} + +async function loadMore() { + if (!data.value || !hasMore.value || loadingMore.value || (redisKind.value === "hash" && searchLoading.value)) return; + if (!(redisKind.value === "list" || redisKind.value === "set" || redisKind.value === "hash" || redisKind.value === "zset")) return; + const keyType = redisKind.value; + const hashFilter = keyType === "hash" ? activeHashSearchQuery.value || undefined : undefined; + const requestId = hashSearchRequestId; + loadingMore.value = true; + try { + const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, hashFilter); + if (keyType === "hash" && requestId !== hashSearchRequestId) return; + const newItems = redisCollectionPageItems(result); + collectionItems.value = [...collectionItems.value, ...newItems]; + scanCursor.value = result.scan_cursor ?? undefined; + } finally { + loadingMore.value = false; + } +} + +async function saveString() { + if (!data.value || !stringBlob.value || isBinaryStringValue.value) return; + await api.redisSetString(props.connectionId, props.db, props.keyRaw, editValue.value); + isEditing.value = false; + await load(); +} + +function handleStringInput() { + if (canEditCurrentStringFormat.value) { + isEditing.value = true; + } +} + +async function saveJson() { + if (!data.value || data.value.data.kind !== "json") return; + await api.redisJsonSet(props.connectionId, props.db, props.keyRaw, editValue.value); + isEditing.value = false; + await load(); +} + +function handleJsonInput() { + if (redisJsonView.value === "raw") { + isEditing.value = true; + } +} + +function handleFormatJsonEditor() { const result = formatJsonText(editValue.value); if (result != null) { editValue.value = result; @@ -372,7 +512,7 @@ function handleFormatStringJson() { } } -function handleCompressStringJson() { +function handleCompressJsonEditor() { const result = compressJsonText(editValue.value); if (result != null) { editValue.value = result; @@ -382,26 +522,6 @@ function handleCompressStringJson() { } } -function handleFormatMemberJson() { - const result = formatJsonText(memberEditValue.value); - if (result != null) { - memberEditValue.value = result; - isEditingMember.value = true; - } else { - toast(t("redis.jsonFormatError"), 3000); - } -} - -function handleCompressMemberJson() { - const result = compressJsonText(memberEditValue.value); - if (result != null) { - memberEditValue.value = result; - isEditingMember.value = true; - } else { - toast(t("redis.jsonFormatError"), 3000); - } -} - async function applyDeleteKey() { await api.redisDeleteKey(props.connectionId, props.db, props.keyRaw); emit("deleted"); @@ -414,7 +534,7 @@ function requestDeleteKey() { async function copyValue() { if (!data.value) return; - const text = typeof data.value.value === "string" ? data.value.value : JSON.stringify(data.value.value, null, 2); + const text = redisValueCopyText(data.value, collectionItems.value); try { await copyToClipboard(text); toast(t("redis.copied"), 2000); @@ -436,63 +556,71 @@ function escapeRedisArg(val: string): string { return `"${val.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } +function blobWriteText(blob: RedisBlob): string | null { + return redisBlobText(blob); +} + function generateInsertStatements(): string | null { if (!data.value) return null; - if (data.value.value_is_binary) return null; const key = data.value.key_display; - const type = data.value.key_type; const commands: string[] = []; - - const isCollection = ["list", "set", "zset", "hash"].includes(type); - if (isCollection) { - const total = data.value.total; - const loaded = collectionItems.value.length; - if (total != null && total > loaded) { - commands.push(`-- Note: Only ${loaded} of ${total} items included`); - } + const total = collectionTotal.value; + if (total != null && total > collectionItems.value.length) { + commands.push(`-- Note: Only ${collectionItems.value.length} of ${total} items included`); } - switch (type) { - case "string": - commands.push(`SET ${escapeRedisArg(key)} ${escapeRedisArg(String(data.value.value))}`); + switch (data.value.data.kind) { + case "string": { + const text = blobWriteText(data.value.data.content); + if (text == null) return null; + commands.push(`SET ${escapeRedisArg(key)} ${escapeRedisArg(text)}`); break; + } + case "json": { + commands.push(`JSON.SET ${escapeRedisArg(key)} $ ${escapeRedisArg(JSON.stringify(data.value.data.value))}`); + break; + } case "list": { - const items = collectionItems.value.map((v) => escapeRedisArg(String(v))).join(" "); - commands.push(`RPUSH ${escapeRedisArg(key)} ${items}`); + const items = (collectionItems.value as RedisListItem[]).map((item) => blobWriteText(item.value)); + if (items.some((item) => item == null)) return null; + commands.push(`RPUSH ${escapeRedisArg(key)} ${(items as string[]).map(escapeRedisArg).join(" ")}`); break; } case "set": { - const members = collectionItems.value.map((v) => escapeRedisArg(String(v))).join(" "); - commands.push(`SADD ${escapeRedisArg(key)} ${members}`); + const items = (collectionItems.value as RedisSetItem[]).map((item) => blobWriteText(item.member)); + if (items.some((item) => item == null)) return null; + commands.push(`SADD ${escapeRedisArg(key)} ${(items as string[]).map(escapeRedisArg).join(" ")}`); break; } case "zset": { - const pairs = collectionItems.value.map((v) => `${v.score} ${escapeRedisArg(String(v.member))}`).join(" "); - commands.push(`ZADD ${escapeRedisArg(key)} ${pairs}`); + const pairs = (collectionItems.value as RedisZsetItem[]).map((item) => { + const member = blobWriteText(item.member); + return member == null ? null : `${item.score} ${escapeRedisArg(member)}`; + }); + if (pairs.some((item) => item == null)) return null; + commands.push(`ZADD ${escapeRedisArg(key)} ${(pairs as string[]).join(" ")}`); break; } case "hash": { - const pairs = collectionItems.value.map((v) => `${escapeRedisArg(String(v.field))} ${escapeRedisArg(String(v.value))}`).join(" "); - commands.push(`HSET ${escapeRedisArg(key)} ${pairs}`); + const pairs = (collectionItems.value as RedisHashItem[]).map((item) => { + const field = blobWriteText(item.field); + const value = blobWriteText(item.value); + return field == null || value == null ? null : `${escapeRedisArg(field)} ${escapeRedisArg(value)}`; + }); + if (pairs.some((item) => item == null)) return null; + commands.push(`HSET ${escapeRedisArg(key)} ${(pairs as string[]).join(" ")}`); break; } case "stream": { - const entries = Array.isArray(data.value.value) ? data.value.value : []; - for (const entry of entries) { - const fields = Object.entries((entry as any).fields ?? {}) - .map(([f, v]) => `${escapeRedisArg(f)} ${escapeRedisArg(String(v))}`) - .join(" "); + for (const entry of data.value.data.entries) { + const fields = entry.fields.map(({ field, value }) => `${escapeRedisArg(field)} ${escapeRedisArg(value)}`).join(" "); commands.push(`XADD ${escapeRedisArg(key)} * ${fields}`); } break; } default: - if (type === "ReJSON-RL" || type === "JSON") { - const json = JSON.stringify(data.value.value); - commands.push(`JSON.SET ${escapeRedisArg(key)} $ '${json}'`); - } - break; + return null; } if (data.value.ttl > 0) { @@ -503,13 +631,11 @@ function generateInsertStatements(): string | null { } async function copyInsertStatement() { - if (!data.value) return; - if (data.value.value_is_binary) { + const stmt = generateInsertStatements(); + if (!stmt) { toast(t("redis.copyInsertStatementBinary"), 3000); return; } - const stmt = generateInsertStatements(); - if (!stmt) return; try { await copyToClipboard(stmt); toast(t("redis.copyInsertStatement"), 2000); @@ -519,18 +645,18 @@ async function copyInsertStatement() { } function copyMember(value: unknown) { - void copyText(formatRedisMemberDetail(value).rawText); + void copyText(redisMemberCopyText(value)); } -function selectMember(title: string, value: unknown, context: RedisMemberContext) { - const detail = formatRedisMemberDetail(value); +function selectMember(title: string, value: unknown, context: RedisMemberContext, identity?: string) { + const detail = formatRedisMemberDetail(value, { allowJsonText: true }); selectedMemberTitle.value = title; selectedMemberRaw.value = value; - selectedMemberKey.value = getRedisMemberSelectionKey(title, value); + selectedMemberKey.value = getRedisMemberSelectionKey(title, value, identity); selectedMemberContext.value = context; isEditingMember.value = false; memberEditValue.value = detail.rawText; - memberValueView.value = detail.format === "json" ? "json" : "raw"; + memberValueView.value = preferredRedisValueFormat(value, readPreferredRedisValueFormat(), { allowJsonText: true }); } function clearSelectedMember() { @@ -542,12 +668,12 @@ function clearSelectedMember() { memberEditValue.value = ""; } -function isSelectedMember(title: string, value: unknown) { - return selectedMemberKey.value === getRedisMemberSelectionKey(title, value); +function isSelectedMember(title: string, value: unknown, identity?: string) { + return selectedMemberKey.value === getRedisMemberSelectionKey(title, value, identity); } -function viewMember(title: string, value: unknown, context: RedisMemberContext) { - selectMember(title, value, context); +function viewMember(title: string, value: unknown, context: RedisMemberContext, identity?: string) { + selectMember(title, value, context, identity); showMemberDetail.value = true; } @@ -635,6 +761,7 @@ function startResizeZsetColumns(event: PointerEvent) { } function startEditMember() { + if (!canEditCurrentMemberFormat.value) return; memberEditValue.value = selectedMemberDetail.value.rawText; isEditingMember.value = true; } @@ -646,79 +773,174 @@ function cancelEditMember() { async function saveMemberEdit() { const context = selectedMemberContext.value; - if (!context || !selectedMemberCanEdit.value) return; - const title = selectedMemberTitle.value; + if (!context || !canEditCurrentMemberFormat.value) return; let nextContext: RedisMemberContext = context; savingMember.value = true; try { if (context.kind === "list") { await api.redisListSet(props.connectionId, props.db, props.keyRaw, context.index, memberEditValue.value); } else if (context.kind === "hash") { + if (!context.field) return; await api.redisHashSet(props.connectionId, props.db, props.keyRaw, context.field, memberEditValue.value); } else if (context.kind === "set") { + if (!context.member) return; await api.redisSetRemove(props.connectionId, props.db, props.keyRaw, context.member); await api.redisSetAdd(props.connectionId, props.db, props.keyRaw, memberEditValue.value); - nextContext = { kind: "set", member: memberEditValue.value }; + nextContext = { kind: "set", member: memberEditValue.value, canEdit: true }; } else if (context.kind === "zset") { + if (!context.member) return; await api.redisZrem(props.connectionId, props.db, props.keyRaw, context.member); - await api.redisZadd(props.connectionId, props.db, props.keyRaw, memberEditValue.value, context.score); - nextContext = { kind: "zset", member: memberEditValue.value, score: context.score }; + await api.redisZadd(props.connectionId, props.db, props.keyRaw, memberEditValue.value, Number(context.score)); + nextContext = { kind: "zset", member: memberEditValue.value, score: context.score, canEdit: true }; } const editedValue = memberEditValue.value; isEditingMember.value = false; await load({ selectDefaultMember: false }); - selectMember(title, editedValue, nextContext); + restoreSelectedMember(nextContext, editedValue); } finally { savingMember.value = false; } } function selectDefaultMember(redisValue: RedisValue) { - if (redisValue.key_type === "list" || redisValue.key_type === "set") { - if (collectionItems.value.length === 0) { - clearSelectedMember(); - return; + switch (redisValue.data.kind) { + case "list": { + const first = redisValue.data.items[0]; + if (!first) return clearSelectedMember(); + return selectMember(`#${first.index}`, first.value, { kind: "list", index: first.index, canEdit: canEditRedisMemberDetail("list", first.value) }); } - selectMember(redisValue.key_type === "list" ? "#0" : t("redis.member"), collectionItems.value[0], redisValue.key_type === "list" ? { kind: "list", index: 0 } : { kind: "set", member: String(collectionItems.value[0]) }); + case "set": { + const first = redisValue.data.items[0]; + if (!first) return clearSelectedMember(); + const member = redisBlobText(first.member); + return selectMember(t("redis.member"), first.member, { kind: "set", member, canEdit: member != null && canEditRedisMemberDetail("set", first.member) }); + } + case "hash": { + const first = redisValue.data.items[0]; + if (!first) return clearSelectedMember(); + const field = redisBlobText(first.field); + return selectMember(formatValue(first.field), first.value, { kind: "hash", field, canEdit: field != null && canEditRedisMemberDetail("hash", first.value) }); + } + case "zset": { + const first = redisValue.data.items[0]; + if (!first) return clearSelectedMember(); + const member = redisBlobText(first.member); + return selectMember(first.score, first.member, { kind: "zset", member, score: first.score, canEdit: member != null && canEditRedisMemberDetail("zset", first.member) }); + } + case "stream": { + const firstEntry = redisValue.data.entries[0]; + const firstField = firstEntry?.fields[0]; + if (!firstField) return clearSelectedMember(); + return selectMember(firstField.field, firstField.value, { kind: "stream", field: firstField.field, canEdit: false }, streamFieldSelectionIdentity(firstEntry.id, 0)); + } + default: + clearSelectedMember(); + } +} + +function streamFieldCount(row: RedisStreamRow): number { + return row.entry.fields.length; +} + +function streamFieldSelectionIdentity(entryId: string, fieldIndex: string | number): string { + return `stream:${entryId}:${fieldIndex}`; +} + +function collectionRowId(value: RedisCollectionItem, index: number): string { + if ("index" in value) return `list-${value.index}`; + if ("field" in value) return `hash-${value.field.raw_base64}-${index}`; + if ("score" in value) return `zset-${value.member.raw_base64}-${index}`; + return `set-${value.member.raw_base64}-${index}`; +} + +function canDeleteHashItem(item: RedisHashItem): boolean { + return redisBlobText(item.field) != null; +} + +function canDeleteSetItem(item: RedisSetItem): boolean { + return redisBlobText(item.member) != null; +} + +function canDeleteZsetItem(item: RedisZsetItem): boolean { + return redisBlobText(item.member) != null; +} + +function restoreSelectedMember(context: RedisMemberContext, fallbackValue: string) { + const restored = resolveSelectedMember(context); + if (restored) { + selectMember(restored.title, restored.value, restored.context); return; } + const title = context.kind === "list" ? `#${context.index}` : context.kind === "set" ? t("redis.member") : context.kind === "hash" ? (context.field ?? selectedMemberTitle.value) : context.kind === "zset" ? context.score : context.field; + selectMember(title, fallbackValue, context); +} - if (redisValue.key_type === "hash") { - const first = collectionItems.value[0]; - if (!first) { - clearSelectedMember(); - return; +function resolveSelectedMember(context: RedisMemberContext): { title: string; value: unknown; context: RedisMemberContext } | null { + switch (context.kind) { + case "list": { + if (redisKind.value !== "list") return null; + const item = (collectionItems.value as RedisListItem[]).find((candidate) => candidate.index === context.index); + if (!item) return null; + return { + title: `#${item.index}`, + value: item.value, + context: { kind: "list", index: item.index, canEdit: canEditRedisMemberDetail("list", item.value) }, + }; } - selectMember(String(first.field), first.value, { kind: "hash", field: String(first.field) }); - return; - } - - if (redisValue.key_type === "zset") { - const first = collectionItems.value[0]; - if (!first) { - clearSelectedMember(); - return; + case "set": { + if (redisKind.value !== "set" || !context.member) return null; + const item = (collectionItems.value as RedisSetItem[]).find((candidate) => redisBlobText(candidate.member) === context.member); + if (!item) return null; + const member = redisBlobText(item.member); + return { + title: t("redis.member"), + value: item.member, + context: { kind: "set", member, canEdit: member != null && canEditRedisMemberDetail("set", item.member) }, + }; } - selectMember(String(first.score), first.member, { - kind: "zset", - member: String(first.member), - score: Number(first.score), - }); - return; - } - - if (redisValue.key_type === "stream" && Array.isArray(redisValue.value)) { - const firstEntry = redisValue.value[0]; - const firstField = firstEntry?.fields ? Object.entries(firstEntry.fields)[0] : undefined; - if (!firstField) { - clearSelectedMember(); - return; + case "hash": { + if (redisKind.value !== "hash" || !context.field) return null; + const item = (collectionItems.value as RedisHashItem[]).find((candidate) => redisBlobText(candidate.field) === context.field); + if (!item) return null; + const field = redisBlobText(item.field); + return { + title: formatValue(item.field), + value: item.value, + context: { kind: "hash", field, canEdit: field != null && canEditRedisMemberDetail("hash", item.value) }, + }; } - selectMember(String(firstField[0]), firstField[1], { kind: "stream", field: String(firstField[0]) }); - return; + case "zset": { + if (redisKind.value !== "zset" || !context.member) return null; + const item = (collectionItems.value as RedisZsetItem[]).find((candidate) => redisBlobText(candidate.member) === context.member && candidate.score === context.score); + if (!item) return null; + const member = redisBlobText(item.member); + return { + title: item.score, + value: item.member, + context: { kind: "zset", member, score: item.score, canEdit: member != null && canEditRedisMemberDetail("zset", item.member) }, + }; + } + case "stream": + return null; } +} - clearSelectedMember(); +function requestHashDel(field: string | null) { + if (!field) return; + pendingDelete.value = { kind: "hash", field }; + showDeleteConfirm.value = true; +} + +function requestSetRemove(member: string | null) { + if (!member) return; + pendingDelete.value = { kind: "set", member }; + showDeleteConfirm.value = true; +} + +function requestZsetRemove(member: string | null) { + if (!member) return; + pendingDelete.value = { kind: "zset", member }; + showDeleteConfirm.value = true; } // TTL @@ -760,10 +982,6 @@ async function applyHashDel(field: string) { await api.redisHashDel(props.connectionId, props.db, props.keyRaw, field); await load(); } -function requestHashDel(field: string) { - pendingDelete.value = { kind: "hash", field }; - showDeleteConfirm.value = true; -} // List async function listPush() { @@ -798,10 +1016,6 @@ async function applySetRemove(member: string) { await api.redisSetRemove(props.connectionId, props.db, props.keyRaw, member); await load(); } -function requestSetRemove(member: string) { - pendingDelete.value = { kind: "set", member }; - showDeleteConfirm.value = true; -} // ZSet async function zsetAdd() { @@ -819,10 +1033,6 @@ async function applyZsetRemove(member: string) { await api.redisZrem(props.connectionId, props.db, props.keyRaw, member); await load(); } -function requestZsetRemove(member: string) { - pendingDelete.value = { kind: "zset", member }; - showDeleteConfirm.value = true; -} async function confirmDelete() { const pending = pendingDelete.value; @@ -835,7 +1045,8 @@ async function confirmDelete() { pendingDelete.value = null; } -function formatValue(val: any): string { +function formatValue(val: unknown): string { + if (isRedisBlob(val)) return formatRedisMemberDetail(val).text; if (typeof val === "string") return formatRedisMemberDetail(val).text; return JSON.stringify(val, null, 2); } @@ -878,7 +1089,7 @@ onBeforeUnmount(() => {
- {{ data.key_type }} + {{ data.redis_type }} {{ t("redis.columnSize") }}: {{ metadataSizeLabel }}