diff --git a/apps/desktop/src/components/redis/RedisJsonEditor.vue b/apps/desktop/src/components/redis/RedisJsonEditor.vue new file mode 100644 index 000000000..0ded856d4 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisJsonEditor.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index dd65e7cbf..8182fdb34 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 { Copy, Eye, Terminal, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, IndentIncrease, IndentDecrease, ArrowUp, ArrowDown, ArrowUpDown, Search, Clock } from "@lucide/vue"; +import { Copy, Eye, Terminal, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, Clock } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -11,6 +11,7 @@ import { Switch } from "@/components/ui/switch"; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import JsonTree from "@/components/common/JsonTree.vue"; +import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue"; import * as api from "@/lib/backend/api"; import type { RedisBlob, RedisHashItem, RedisKeyInfo, RedisListItem, RedisSetItem, RedisStreamEntry, RedisValue, RedisZsetItem } from "@/lib/backend/api"; import { useToast } from "@/composables/useToast"; @@ -31,6 +32,8 @@ import { REDIS_VALUE_FORMAT_DISPLAY_ORDER, redisBlobText, redisCollectionPageItems, + redisJsonValueText, + normalizeRedisJsonDraft, redisMemberCopyText, redisValueCopyText, redisValueCollectionItems, @@ -39,7 +42,7 @@ import { type RedisCollectionItem, type RedisValueFormat, } from "@/lib/redis/redisValuePresentation"; -import { safeJsonFormat } from "@/lib/common/safeJsonFormat"; +import { formatJsonSource } from "@/lib/common/safeJsonFormat"; const { t } = useI18n(); const { toast } = useToast(); @@ -64,8 +67,10 @@ const REDIS_STREAM_MIN_ROW_HEIGHT = 96; const data = ref(null); const loading = ref(false); const loadingMore = ref(false); +let loadRequestId = 0; const editValue = ref(""); -const isEditing = ref(false); +const savingString = ref(false); +const savingJson = ref(false); const newField = ref(""); const newValue = ref(""); const newScore = ref(""); @@ -97,7 +102,6 @@ const zsetScoreWidth = ref(220); const isResizingZsetColumns = ref(false); const stringValueView = ref(readPreferredRedisValueFormat()); const memberValueView = ref(readPreferredRedisValueFormat()); -const redisJsonView = ref<"raw" | "tree">("raw"); const redisJsonWordWrap = ref(readRedisJsonWordWrap()); const redisJsonHighlighter = ref(); @@ -127,9 +131,11 @@ function startAutoRefresh() { return; } if (action.type === "refresh") { - load() - .then(() => { - if (!autoRefreshEnabled.value) return; + // Do not let a background refresh overwrite a Redis value draft. + if (hasUnsavedRedisDraft.value) return; + load({ preserveDraft: true }) + .then((applied) => { + if (!applied || !autoRefreshEnabled.value) return; if (!data.value || shouldStopAutoRefresh(data.value.ttl)) { stopAutoRefresh(); autoRefreshEnabled.value = false; @@ -178,16 +184,52 @@ const stringBlob = computed(() => { 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 canEditCurrentStringFormat = computed(() => Boolean(stringValueDetail.value?.editable) && (stringValueView.value === "utf8" || stringValueView.value === "json")); const showStringEditActions = computed(() => canEditCurrentStringFormat.value); const originalStringEditValue = computed(() => (stringBlob.value ? rawRedisValueText(stringBlob.value) : "")); -const stringValueChanged = computed(() => canEditCurrentStringFormat.value && editValue.value !== originalStringEditValue.value); +const stringJsonDraftBaseline = ref(""); +const redisJsonDraftBaseline = ref(""); +const memberJsonDraftBaseline = ref(""); +// Keep the comparison semantics from the last editable String view. A draft is +// retained when the user switches to a read-only representation such as Hex. +const stringDraftFormat = ref<"utf8" | "json">("utf8"); +function isStringDraftDirty(format: "utf8" | "json"): boolean { + if (!stringValueDetail.value?.editable) return false; + if (format === "json" && stringValueDetail.value.json) return editValue.value !== stringJsonDraftBaseline.value; + return editValue.value !== originalStringEditValue.value; +} +const stringValueChanged = computed(() => { + if (!canEditCurrentStringFormat.value) return false; + return isStringDraftDirty(stringValueView.value === "json" ? "json" : "utf8"); +}); +const hasRetainedStringDraft = computed(() => isStringDraftDirty(stringDraftFormat.value)); +const redisJsonValueChanged = computed(() => data.value?.data.kind === "json" && editValue.value !== redisJsonDraftBaseline.value); const canEditCurrentMemberFormat = computed(() => selectedMemberCanEdit.value && memberValueView.value === "utf8"); +const isEditingHashJson = computed(() => selectedMemberContext.value?.kind === "hash" && selectedMemberCanEdit.value && memberValueView.value === "json" && Boolean(selectedMemberDetail.value.json)); +const memberValueChanged = computed(() => { + if (!selectedMemberCanEdit.value) return false; + const original = selectedMemberDetail.value.rawText; + if (isEditingHashJson.value && selectedMemberDetail.value.json) return memberEditValue.value !== memberJsonDraftBaseline.value; + return memberEditValue.value !== original; +}); +// A member sheet can close without discarding its draft, so its dirty state +// must outlive the sheet and whichever display format is currently selected. +const memberDraftFormat = ref<"utf8" | "json" | null>(null); +const hasRetainedMemberDraft = computed(() => { + const format = memberDraftFormat.value; + if (!format || !selectedMemberCanEdit.value) return false; + + const original = selectedMemberDetail.value.rawText; + if (format === "json" && selectedMemberContext.value?.kind === "hash" && selectedMemberDetail.value.json) { + return memberEditValue.value !== memberJsonDraftBaseline.value; + } + return memberEditValue.value !== original; +}); +const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value); const hasMore = computed(() => scanCursor.value != null && scanCursor.value > 0); const collectionTotal = computed(() => (data.value ? redisValueCollectionTotal(data.value) : null)); const hashGridStyle = computed(() => ({ @@ -332,7 +374,7 @@ async function onHashSearch() { activeHashSearchQuery.value = query; collectionItems.value = result.items; scanCursor.value = result.scan_cursor ?? undefined; - clearSelectedMember(); + if (!hasRetainedMemberDraft.value) clearSelectedMember(); } finally { if (requestId === hashSearchRequestId) searchLoading.value = false; } @@ -367,18 +409,15 @@ function readPreferredRedisValueFormat(): RedisValueFormat { function formatJsonText(raw: string): string | null { try { - return safeJsonFormat(raw, 2); + // Keep Redis JSON baselines source-preserving (duplicate keys, number text). + return formatJsonSource(raw, 2); } catch { return null; } } -function compressJsonText(raw: string): string | null { - try { - return safeJsonFormat(raw); - } catch { - return null; - } +function jsonDraftForEditor(raw: string): string { + return formatJsonText(raw) ?? raw; } function rememberRedisValueFormat(format: RedisValueFormat) { @@ -390,15 +429,41 @@ function rememberRedisValueFormat(format: RedisValueFormat) { } function setStringValueFormat(format: RedisValueFormat) { + if (stringValueView.value === "json" && format !== "json" && stringValueDetail.value?.json && editValue.value === stringJsonDraftBaseline.value) { + editValue.value = originalStringEditValue.value; + stringDraftFormat.value = "utf8"; + } stringValueView.value = format; if (stringValueDetail.value && canRenderRedisValueFormat(stringValueDetail.value, format)) { + if (format === "json") { + editValue.value = editValue.value === originalStringEditValue.value ? stringJsonDraftBaseline.value : jsonDraftForEditor(editValue.value); + stringDraftFormat.value = "json"; + } else if (format === "utf8") { + // Keep a dirty JSON draft marked as json so save still compact-writes after a tab switch. + if (!(stringDraftFormat.value === "json" && isStringDraftDirty("json"))) stringDraftFormat.value = "utf8"; + } rememberRedisValueFormat(format); } } function setMemberValueFormat(format: RedisValueFormat) { + if (memberValueView.value === "json" && format !== "json" && selectedMemberDetail.value.json && memberEditValue.value === memberJsonDraftBaseline.value) { + memberEditValue.value = selectedMemberDetail.value.rawText; + // Mirror string drafts: a clean leave from JSON must drop the pretty baseline + // comparison, or rawText vs formattedText looks dirty and blocks refresh. + if (selectedMemberContext.value?.kind === "hash" && selectedMemberCanEdit.value) memberDraftFormat.value = null; + } memberValueView.value = format; if (canRenderRedisValueFormat(selectedMemberDetail.value, format)) { + if (format === "json") { + memberEditValue.value = memberEditValue.value === selectedMemberDetail.value.rawText ? memberJsonDraftBaseline.value : jsonDraftForEditor(memberEditValue.value); + if (selectedMemberContext.value?.kind === "hash" && selectedMemberCanEdit.value) memberDraftFormat.value = "json"; + } else if (format === "utf8") { + // Dirty JSON drafts keep format "json" so save/normalize still runs after leaving the JSON tab. + if (selectedMemberContext.value?.kind === "hash" && selectedMemberCanEdit.value && memberDraftFormat.value !== "json") { + memberDraftFormat.value = "utf8"; + } + } rememberRedisValueFormat(format); } } @@ -425,7 +490,7 @@ function redisFormatLabel(format: RedisValueFormat, rawLabel?: string): string { } function isTextRedisFormat(format: RedisValueFormat): boolean { - return format === "utf8" || format === "ascii" || format === "binary"; + return format === "utf8" || format === "ascii" || format === "binary" || format === "json"; } function highlightRedisJson(json: string): string { @@ -477,31 +542,43 @@ const deleteDetails = computed(() => { return t("dangerDialog.redisSetMemberDetails", { key, member: formatValue(pending.member) }); }); -async function load(options: { selectDefaultMember?: boolean } = {}) { +async function load(options: { selectDefaultMember?: boolean; preserveDraft?: boolean } = {}): Promise { const shouldSelectDefaultMember = options.selectDefaultMember ?? true; - if (hashSearchTimer) clearTimeout(hashSearchTimer); - hashSearchTimer = null; - hashSearchRequestId++; - hashSearchQuery.value = ""; - activeHashSearchQuery.value = ""; - searchLoading.value = false; + const requestId = ++loadRequestId; loading.value = true; try { const loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw); + if (requestId !== loadRequestId || (options.preserveDraft && hasUnsavedRedisDraft.value)) return false; + + if (hashSearchTimer) clearTimeout(hashSearchTimer); + hashSearchTimer = null; + hashSearchRequestId++; + hashSearchQuery.value = ""; + activeHashSearchQuery.value = ""; + searchLoading.value = false; data.value = loadedValue; emit("loaded", loadedValue); scanCursor.value = redisValueCollectionScanCursor(loadedValue); collectionItems.value = redisValueCollectionItems(loadedValue); - isEditing.value = false; + + // A foreground load replaces the current value, so it also starts a new + // draft lifecycle. Member saves opt out until selection is restored. + if (shouldSelectDefaultMember) { + stringDraftFormat.value = "utf8"; + memberDraftFormat.value = null; + } 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 }); + stringJsonDraftBaseline.value = detail.json?.formattedText ?? ""; + editValue.value = stringValueView.value === "json" && detail.json ? stringJsonDraftBaseline.value : detail.rawText; + stringDraftFormat.value = stringValueView.value === "json" ? "json" : "utf8"; clearSelectedMember(); } else if (loadedValue.data.kind === "json") { - editValue.value = JSON.stringify(loadedValue.data.value, null, 2); - redisJsonView.value = "raw"; + redisJsonDraftBaseline.value = jsonDraftForEditor(redisJsonValueText(loadedValue.data)); + editValue.value = redisJsonDraftBaseline.value; + stringDraftFormat.value = "utf8"; clearSelectedMember(); } else if (loadedValue.data.kind === "stream") { if (shouldSelectDefaultMember) selectDefaultMember(loadedValue); @@ -510,10 +587,16 @@ async function load(options: { selectDefaultMember?: boolean } = {}) { } else { clearSelectedMember(); } + return true; + } catch (error) { + if (requestId !== loadRequestId) return false; + throw error; } finally { - loading.value = false; - if (autoRefreshEnabled.value && data.value && data.value.ttl > 0) { - startAutoRefresh(); + if (requestId === loadRequestId) { + loading.value = false; + if (autoRefreshEnabled.value && data.value && data.value.ttl > 0) { + startAutoRefresh(); + } } } } @@ -537,54 +620,52 @@ async function loadMore() { } async function saveString() { - if (!data.value || !stringBlob.value || isBinaryStringValue.value || !stringValueChanged.value) return; - await api.redisSetString(props.connectionId, props.db, props.keyRaw, editValue.value); - isEditing.value = false; - await load(); -} + if (!data.value || !stringBlob.value || isBinaryStringValue.value || !stringValueChanged.value || savingString.value) return; -function handleStringInput() { - if (canEditCurrentStringFormat.value) { - isEditing.value = stringValueChanged.value; + let value = editValue.value; + // Compact whenever this draft is/was JSON-edited, even if the user switched tabs before Save. + if (stringValueView.value === "json" || stringDraftFormat.value === "json") { + const normalized = normalizeRedisJsonDraft(value); + if (!normalized.ok) { + toast(t("redis.jsonFormatError"), 3000); + return; + } + value = normalized.compactText; + } + + savingString.value = true; + try { + await api.redisSetString(props.connectionId, props.db, props.keyRaw, value); + await load(); + } finally { + savingString.value = false; } } function discardStringEdit() { - isEditing.value = false; - editValue.value = originalStringEditValue.value; + editValue.value = stringValueView.value === "json" ? stringJsonDraftBaseline.value : originalStringEditValue.value; + stringDraftFormat.value = stringValueView.value === "json" ? "json" : "utf8"; } 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; - isEditing.value = true; - } else { + if (!data.value || data.value.data.kind !== "json" || !redisJsonValueChanged.value || savingJson.value) return; + const normalized = normalizeRedisJsonDraft(editValue.value); + if (!normalized.ok) { toast(t("redis.jsonFormatError"), 3000); + return; + } + savingJson.value = true; + try { + // Keep JSON.SET semantics while matching the other JSON editors' validation and compact writes. + await api.redisJsonSet(props.connectionId, props.db, props.keyRaw, normalized.compactText); + await load(); + } finally { + savingJson.value = false; } } -function handleCompressJsonEditor() { - const result = compressJsonText(editValue.value); - if (result != null) { - editValue.value = result; - isEditing.value = true; - } else { - toast(t("redis.jsonFormatError"), 3000); - } +function discardRedisJsonEdit() { + editValue.value = redisJsonDraftBaseline.value; } async function applyDeleteKey() { @@ -643,7 +724,7 @@ function generateInsertStatements(): string | null { break; } case "json": { - commands.push(`JSON.SET ${escapeRedisArg(key)} $ ${escapeRedisArg(JSON.stringify(data.value.data.value))}`); + commands.push(`JSON.SET ${escapeRedisArg(key)} $ ${escapeRedisArg(redisJsonValueText(data.value.data))}`); break; } case "list": { @@ -720,8 +801,10 @@ function selectMember(title: string, value: unknown, context: RedisMemberContext selectedMemberKey.value = getRedisMemberSelectionKey(title, value, identity); selectedMemberContext.value = context; isEditingMember.value = false; - memberEditValue.value = detail.rawText; memberValueView.value = preferredRedisValueFormat(value, readPreferredRedisValueFormat(), { allowJsonText: true }); + memberJsonDraftBaseline.value = detail.json?.formattedText ?? ""; + memberEditValue.value = memberValueView.value === "json" && detail.json ? memberJsonDraftBaseline.value : detail.rawText; + memberDraftFormat.value = context.kind === "hash" && context.canEdit && memberValueView.value === "json" && detail.json ? "json" : null; } function clearSelectedMember() { @@ -731,6 +814,8 @@ function clearSelectedMember() { selectedMemberContext.value = null; isEditingMember.value = false; memberEditValue.value = ""; + memberJsonDraftBaseline.value = ""; + memberDraftFormat.value = null; } function isSelectedMember(title: string, value: unknown, identity?: string) { @@ -738,7 +823,15 @@ function isSelectedMember(title: string, value: unknown, identity?: string) { } function viewMember(title: string, value: unknown, context: RedisMemberContext, identity?: string) { - selectMember(title, value, context, identity); + // Do not replace a retained draft just because another row was clicked. + // Save or discard it first, then select the next member. + if (!isSelectedMember(title, value, identity) && hasRetainedMemberDraft.value) { + showMemberDetail.value = true; + return; + } + if (!isSelectedMember(title, value, identity) || !memberValueChanged.value) { + selectMember(title, value, context, identity); + } showMemberDetail.value = true; } @@ -827,38 +920,59 @@ function startResizeZsetColumns(event: PointerEvent) { function startEditMember() { if (!canEditCurrentMemberFormat.value) return; - memberEditValue.value = selectedMemberDetail.value.rawText; + if (!memberValueChanged.value) memberEditValue.value = selectedMemberDetail.value.rawText; + // Do not demote a retained JSON draft to utf8; save still needs compact normalization. + if (memberDraftFormat.value !== "json") memberDraftFormat.value = "utf8"; isEditingMember.value = true; } function cancelEditMember() { memberEditValue.value = selectedMemberDetail.value.rawText; + memberDraftFormat.value = null; isEditingMember.value = false; } +function discardHashJsonEdit() { + memberEditValue.value = memberJsonDraftBaseline.value; + memberDraftFormat.value = "json"; +} + async function saveMemberEdit() { const context = selectedMemberContext.value; - if (!context || !canEditCurrentMemberFormat.value) return; + const savingHashJson = isEditingHashJson.value || (context?.kind === "hash" && memberDraftFormat.value === "json"); + if (!context || savingMember.value || (!canEditCurrentMemberFormat.value && !savingHashJson)) return; + + let writeValue = memberEditValue.value; + // Hash JSON drafts may still be open under UTF-8 after a format switch; keep compact writes. + if (savingHashJson) { + const normalized = normalizeRedisJsonDraft(writeValue); + if (!normalized.ok) { + toast(t("redis.jsonFormatError"), 3000); + return; + } + writeValue = normalized.compactText; + } + 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); + await api.redisListSet(props.connectionId, props.db, props.keyRaw, context.index, writeValue); } else if (context.kind === "hash") { if (!context.field) return; - await api.redisHashSet(props.connectionId, props.db, props.keyRaw, context.field, memberEditValue.value); + await api.redisHashSet(props.connectionId, props.db, props.keyRaw, context.field, writeValue); } 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, canEdit: true }; + await api.redisSetAdd(props.connectionId, props.db, props.keyRaw, writeValue); + nextContext = { kind: "set", member: writeValue, 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, Number(context.score)); - nextContext = { kind: "zset", member: memberEditValue.value, score: context.score, canEdit: true }; + await api.redisZadd(props.connectionId, props.db, props.keyRaw, writeValue, Number(context.score)); + nextContext = { kind: "zset", member: writeValue, score: context.score, canEdit: true }; } - const editedValue = memberEditValue.value; + const editedValue = writeValue; isEditingMember.value = false; await load({ selectDefaultMember: false }); restoreSelectedMember(nextContext, editedValue); @@ -1148,7 +1262,7 @@ onBeforeUnmount(() => { {{ formatValue(data.key_display) }} - + @@ -1195,9 +1309,7 @@ onBeforeUnmount(() => { - - - + @@ -1215,9 +1327,8 @@ onBeforeUnmount(() => { v-model="editValue" class="dbx-editor-font-family flex-1 resize-none bg-background p-4 text-sm outline-none" :class="redisJsonWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'" - :readonly="!canEditCurrentStringFormat" + :readonly="!canEditCurrentStringFormat || savingString" spellcheck="false" - @input="handleStringInput" /> {{ detailTextForFormat(stringValueDetail, stringValueView) }} {{ detailTextForFormat(stringValueDetail, stringValueView) }} @@ -1225,50 +1336,25 @@ onBeforeUnmount(() => { {{ t("redis.binaryStringReadonlyHint") }} - {{ t("grid.discard") }} - {{ t("grid.save") }} + {{ t("grid.discard") }} + {{ t("grid.save") }} - + - - - {{ t("redis.rawContent") }} - - - {{ t("redis.jsonView") }} - - - - - - - - - + {{ t("redis.wordWrap") }} - - - - - - {{ t("grid.discard") }} - {{ t("grid.save") }} + + + {{ t("grid.discard") }} + {{ t("grid.save") }} @@ -1548,7 +1634,7 @@ onBeforeUnmount(() => { - + @@ -1573,7 +1659,8 @@ onBeforeUnmount(() => { - + + @@ -1601,6 +1688,16 @@ onBeforeUnmount(() => { {{ t("grid.save") }} + + + {{ t("grid.discard") }} + + + + + {{ t("grid.save") }} + + {{ t("redis.editMember") }} diff --git a/apps/desktop/src/composables/__tests__/useCellDetailEditor.folding.spec.ts b/apps/desktop/src/composables/__tests__/useCellDetailEditor.folding.spec.ts new file mode 100644 index 000000000..8b5a4eacf --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useCellDetailEditor.folding.spec.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { json } from "@codemirror/lang-json"; +import { foldable } from "@codemirror/language"; + +const redisJsonEditorSource = readFileSync(new URL("../../components/redis/RedisJsonEditor.vue", import.meta.url), "utf8"); +const cellDetailEditorSource = readFileSync(new URL("../useCellDetailEditor.ts", import.meta.url), "utf8"); + +describe("Redis JSON editor folding", () => { + it("opts the Redis JSON editor into the shared folding controls", () => { + expect(redisJsonEditorSource).toMatch(/language:\s*"json"[\s\S]*folding:\s*true/); + expect(cellDetailEditorSource).toContain("...(options.folding ? [foldGutter()] : []),"); + expect(cellDetailEditorSource).toContain("...(options.folding ? foldKeymap : []),"); + }); + + it("provides foldable ranges for JSON objects and arrays", () => { + const state = EditorState.create({ + doc: `{ + "items": [ + 1, + 2 + ] +}`, + extensions: [json()], + }); + + const objectLine = state.doc.line(1); + const arrayLine = state.doc.line(2); + const arrayCloseLine = state.doc.line(5); + + expect(foldable(state, objectLine.from, objectLine.to)).toEqual({ from: objectLine.to, to: state.doc.line(6).from }); + expect(foldable(state, arrayLine.from, arrayLine.to)).toEqual({ from: arrayLine.to, to: arrayCloseLine.from + arrayCloseLine.text.indexOf("]") }); + }); +}); diff --git a/apps/desktop/src/composables/useCellDetailEditor.ts b/apps/desktop/src/composables/useCellDetailEditor.ts index 6d116bedb..0e1834d4d 100644 --- a/apps/desktop/src/composables/useCellDetailEditor.ts +++ b/apps/desktop/src/composables/useCellDetailEditor.ts @@ -1,10 +1,10 @@ import { shallowRef, onBeforeUnmount, getCurrentInstance, type ShallowRef, createApp, watch } from "vue"; import { EditorState, Compartment } from "@codemirror/state"; -import { EditorView, keymap, drawSelection, dropCursor, highlightSpecialChars, highlightActiveLine } from "@codemirror/view"; +import { EditorView, keymap, drawSelection, dropCursor, highlightSpecialChars, highlightActiveLine, highlightActiveLineGutter, lineNumbers } from "@codemirror/view"; import { json } from "@codemirror/lang-json"; import { search as cmSearch } from "@codemirror/search"; import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; -import { bracketMatching } from "@codemirror/language"; +import { bracketMatching, foldGutter, foldKeymap } from "@codemirror/language"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, cellDetailActiveLineColor, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; import { shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; @@ -21,8 +21,16 @@ export interface UseCellDetailEditorOptions { onChange?: (value: string) => void; onEscape?: () => void; onBlur?: () => void; + /** Return true after handling a save shortcut so CodeMirror consumes it. */ + onSaveShortcut?: (event: KeyboardEvent) => boolean; language?: "auto" | "json"; - readOnly?: boolean; + readOnly?: boolean | (() => boolean); + /** Keep cell detail editors gutter-free unless a caller explicitly opts in. */ + lineNumbers?: boolean; + /** A reactive source for opting into CodeMirror line wrapping. */ + lineWrapping?: () => boolean; + /** Add CodeMirror fold controls and keyboard bindings for structured source. */ + folding?: boolean; editorTheme: () => EditorTheme; appAppearance: () => AppThemeAppearance; appPalette: () => AppThemePalette; @@ -62,6 +70,8 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel const languageComp = new Compartment(); const themeComp = new Compartment(); const fontThemeComp = new Compartment(); + const lineWrappingComp = new Compartment(); + const readOnlyComp = new Compartment(); let destroyed = false; let currentIsJson = false; @@ -85,6 +95,14 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel wrapperEl.style.setProperty(EDITOR_FONT_FAMILY_CSS_VAR, fontFamily); } + function isReadOnly(): boolean { + return typeof options.readOnly === "function" ? options.readOnly() : Boolean(options.readOnly); + } + + function readOnlyExtensions(readOnly: boolean) { + return [EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly), EditorView.contentAttributes.of(readOnly ? { tabindex: "0" } : {})]; + } + function reconfigureFontTheme(size: number, family: string) { const editor = view.value; if (!editor) return; @@ -146,6 +164,24 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel }); }); + watch( + () => options.lineWrapping?.() ?? false, + (lineWrapping) => { + const editor = view.value; + if (!editor || destroyed) return; + editor.dispatch({ effects: lineWrappingComp.reconfigure(lineWrapping ? EditorView.lineWrapping : []) }); + }, + ); + + watch( + () => isReadOnly(), + (readOnly) => { + const editor = view.value; + if (!editor || destroyed) return; + editor.dispatch({ effects: readOnlyComp.reconfigure(readOnlyExtensions(readOnly)) }); + }, + ); + async function create(parent: HTMLElement, initialValue: string, columnType?: string): Promise { if (destroyed) return; @@ -153,6 +189,7 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel currentIsJson = options.language === "json" || shouldUseJsonMode(columnType, doc); const theme = await loadEditorTheme(options.editorTheme(), options.appAppearance(), undefined, options.appPalette()); + if (destroyed) return; liveFontSize = clampEditorFontSize(options.fontSize()); const fontTheme = editorFontTheme(EditorView, liveFontSize, options.fontFamily(), { fixedHeight: true, scrollable: true }); const shortcuts = settingsStore.editorSettings.shortcuts; @@ -167,7 +204,9 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel return { dom }; }, }), - // Minimal setup without line numbers + // Keep the compact detail-editor baseline; gutters and wrapping are opt-in. + ...(options.lineNumbers ? [lineNumbers(), highlightActiveLineGutter()] : []), + ...(options.folding ? [foldGutter()] : []), highlightSpecialChars(), history(), drawSelection(), @@ -184,6 +223,7 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel keymap.of([ ...defaultKeymap, ...historyKeymap, + ...(options.folding ? foldKeymap : []), { key: shortcutToCodeMirrorKey(shortcuts.find), preventDefault: true, @@ -196,6 +236,8 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel }, ]), languageComp.of(currentIsJson ? json() : []), + lineWrappingComp.of(options.lineWrapping?.() ? EditorView.lineWrapping : []), + readOnlyComp.of(readOnlyExtensions(isReadOnly())), themeComp.of(theme), fontThemeComp.of(fontTheme), keymap.of([ @@ -218,6 +260,12 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel } }), EditorView.domEventHandlers({ + keydown(event) { + if (!options.onSaveShortcut?.(event)) return false; + event.preventDefault(); + event.stopPropagation(); + return true; + }, wheel(event) { if (!event.metaKey && !event.ctrlKey) return false; event.preventDefault(); @@ -230,14 +278,13 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel options.onBlur?.(); }, }), - EditorState.readOnly.of(!!options.readOnly), - EditorView.editable.of(!options.readOnly), - // Read-only CodeMirror content is not editable or focusable by default. - // Keep it keyboard-focusable so selection shortcuts stay inside the detail value. - EditorView.contentAttributes.of(options.readOnly ? { tabindex: "0" } : {}), ], }); + // Re-check after the async theme load: a fast v-if unmount can destroy this + // instance while create is still in flight. + if (destroyed) return; + wrapperEl = document.createElement("div"); wrapperEl.style.cssText = "position: relative; width: 100%; height: 100%;"; wrapperEl.addEventListener("gesturestart", onEditorGestureStart); @@ -254,6 +301,11 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel searchApp = createApp(EditorSearchPanel, { view: view.value }); searchApp.use(i18n); searchInstance = searchApp.mount(searchMount) as any; + + // If unmounted during the last sync steps, drop the late-mounted editor. + if (destroyed) { + destroy(); + } } function setValue(value: string, columnType?: string) { @@ -288,14 +340,14 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel } function destroy() { - if (destroyed) return; + const alreadyDestroyed = destroyed; destroyed = true; searchApp?.unmount(); searchApp = null; searchInstance = null; view.value?.destroy(); view.value = null; - zoomCommitScheduler.dispose(); + if (!alreadyDestroyed) zoomCommitScheduler.dispose(); if (wrapperEl) { wrapperEl.removeEventListener("gesturestart", onEditorGestureStart); wrapperEl.removeEventListener("gesturechange", onEditorGestureChange); diff --git a/apps/desktop/src/lib/__tests__/redis/RedisValueViewer.redisJson.spec.ts b/apps/desktop/src/lib/__tests__/redis/RedisValueViewer.redisJson.spec.ts new file mode 100644 index 000000000..e30349a77 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/redis/RedisValueViewer.redisJson.spec.ts @@ -0,0 +1,245 @@ +import { readFileSync } from "node:fs"; +import { parse } from "vue/compiler-sfc"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import { formatRedisMemberDetail, normalizeRedisJsonDraft } from "@/lib/redis/redisValuePresentation"; + +const viewerSource = readFileSync(new URL("../../../components/redis/RedisValueViewer.vue", import.meta.url), "utf8"); +const parsedViewer = parse(viewerSource, { filename: "RedisValueViewer.vue" }); + +/** Owner-review fixture: compact save must not drop the second "role" member. */ +const DUPLICATE_MEMBER_COMPACT = '{"role":"reader","role":"writer"}'; +const DUPLICATE_MEMBER_PRETTY = `{ + "role": "reader", + "role": "writer" +}`; + +type TemplateElement = { + type: number; + tag: string; + children?: TemplateNode[]; + props: Array<{ + type: number; + name?: string; + arg?: { content?: string }; + exp?: { content?: string }; + }>; +}; + +type TemplateNode = { + type: number; + children?: TemplateNode[]; +}; + +function directiveExpression(element: TemplateElement, name: string, arg?: string): string | undefined { + return element.props.find((prop) => prop.type === 7 && prop.name === name && (arg == null || prop.arg?.content === arg))?.exp?.content; +} + +function templateElements(node: TemplateNode): TemplateElement[] { + const children = (node.children ?? []).flatMap(templateElements); + return node.type === 1 ? [node as TemplateElement, ...children] : children; +} + +function findTemplateElement(predicate: (element: TemplateElement) => boolean): TemplateElement { + const template = parsedViewer.descriptor.template; + expect(parsedViewer.errors).toEqual([]); + expect(template).toBeDefined(); + + const element = templateElements(template!.ast as unknown as TemplateElement).find(predicate); + expect(element).toBeDefined(); + return element!; +} + +function findFunction(name: string): ts.FunctionDeclaration { + const script = parsedViewer.descriptor.scriptSetup; + expect(script).toBeDefined(); + + const source = ts.createSourceFile("RedisValueViewer.vue.ts", script!.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const declaration = source.statements.find((statement): statement is ts.FunctionDeclaration => ts.isFunctionDeclaration(statement) && statement.name?.text === name); + expect(declaration).toBeDefined(); + return declaration!; +} + +function findVariableInitializer(name: string): ts.Expression { + const script = parsedViewer.descriptor.scriptSetup; + expect(script).toBeDefined(); + + const source = ts.createSourceFile("RedisValueViewer.vue.ts", script!.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue; + const declaration = statement.declarationList.declarations.find((candidate) => ts.isIdentifier(candidate.name) && candidate.name.text === name); + if (declaration?.initializer) return declaration.initializer; + } + + throw new Error(`Expected ${name} to have an initializer`); +} + +function callsIn(node: ts.Node): ts.CallExpression[] { + const calls: ts.CallExpression[] = []; + const visit = (child: ts.Node) => { + if (ts.isCallExpression(child)) calls.push(child); + ts.forEachChild(child, visit); + }; + visit(node); + return calls; +} + +function assignmentsIn(node: ts.Node): ts.BinaryExpression[] { + const assignments: ts.BinaryExpression[] = []; + const visit = (child: ts.Node) => { + if (ts.isBinaryExpression(child) && child.operatorToken.kind === ts.SyntaxKind.EqualsToken) assignments.push(child); + ts.forEachChild(child, visit); + }; + visit(node); + return assignments; +} + +function calledName(call: ts.CallExpression): string | undefined { + if (ts.isIdentifier(call.expression)) return call.expression.text; + if (ts.isPropertyAccessExpression(call.expression)) return `${call.expression.expression.getText()}.${call.expression.name.text}`; + return undefined; +} + +describe("native RedisJSON editor", () => { + it("uses the same foldable source editor as JSON strings and hash fields", () => { + const stringEditor = findTemplateElement((element) => element.tag === "RedisJsonEditor" && directiveExpression(element, "if") === "stringValueView === 'json' && stringValueDetail.json"); + const nativeJsonBranch = findTemplateElement((element) => directiveExpression(element, "else-if") === "redisKind === 'json'"); + const hashEditor = findTemplateElement((element) => element.tag === "RedisJsonEditor" && directiveExpression(element, "if") === "isEditingHashJson"); + const nativeJsonEditors = templateElements(nativeJsonBranch).filter((element) => element.tag === "RedisJsonEditor"); + + expect(stringEditor.tag).toBe("RedisJsonEditor"); + expect(hashEditor.tag).toBe("RedisJsonEditor"); + expect(nativeJsonEditors).toHaveLength(1); + + const [nativeJsonEditor] = nativeJsonEditors; + expect(directiveExpression(nativeJsonEditor, "model")).toBe("editValue"); + expect(directiveExpression(nativeJsonEditor, "bind", "save-disabled")).toBe("savingJson || !redisJsonValueChanged"); + expect(directiveExpression(nativeJsonEditor, "bind", "read-only")).toBe("savingJson"); + expect(directiveExpression(nativeJsonEditor, "bind", "word-wrap")).toBe("redisJsonWordWrap"); + expect(directiveExpression(nativeJsonEditor, "on", "save")).toBe("saveJson"); + }); + + it("does not leave a native RedisJSON tree or textarea rendering branch behind", () => { + const nativeJsonBranch = findTemplateElement((element) => directiveExpression(element, "else-if") === "redisKind === 'json'"); + const nativeJsonTags = templateElements(nativeJsonBranch).map((element) => element.tag); + + expect(nativeJsonTags).not.toContain("JsonTree"); + expect(nativeJsonTags).not.toContain("textarea"); + }); + + it("validates and compacts native RedisJSON before retaining JSON.SET save semantics", () => { + const saveJson = findFunction("saveJson"); + const calls = callsIn(saveJson); + const normalizeCall = calls.find((call) => calledName(call) === "normalizeRedisJsonDraft"); + const redisJsonSetCall = calls.find((call) => calledName(call) === "api.redisJsonSet"); + + expect(normalizeCall?.arguments.map((argument) => argument.getText())).toEqual(["editValue.value"]); + expect(redisJsonSetCall?.arguments.map((argument) => argument.getText())).toEqual(["props.connectionId", "props.db", "props.keyRaw", "normalized.compactText"]); + }); + + it("routes string and hash JSON saves through the same source-preserving normalize helper", () => { + const saveString = findFunction("saveString"); + const saveMemberEdit = findFunction("saveMemberEdit"); + const saveStringText = saveString.getText(); + const saveMemberText = saveMemberEdit.getText(); + const saveStringCalls = callsIn(saveString).map(calledName); + const saveMemberCalls = callsIn(saveMemberEdit).map(calledName); + + // String JSON draft (current tab or retained draft format) compact-writes via SET. + expect(saveStringText).toContain('stringValueView.value === "json" || stringDraftFormat.value === "json"'); + expect(saveStringCalls).toContain("normalizeRedisJsonDraft"); + expect(saveStringText).toContain("value = normalized.compactText"); + expect(saveStringCalls).toContain("api.redisSetString"); + expect(saveStringText).toMatch(/redisSetString\([\s\S]*\bvalue\b/); + + // Hash JSON draft (editor open or retained after leaving JSON tab) compact-writes via HSET. + expect(saveMemberText).toContain("savingHashJson"); + expect(saveMemberText).toContain('memberDraftFormat.value === "json"'); + expect(saveMemberCalls).toContain("normalizeRedisJsonDraft"); + expect(saveMemberText).toContain("writeValue = normalized.compactText"); + expect(saveMemberCalls).toContain("api.redisHashSet"); + expect(saveMemberText).toMatch(/redisHashSet\([\s\S]*\bwriteValue\b/); + + // Editor pretty baseline also uses the source-preserving formatter. + expect(findFunction("formatJsonText").getText()).toContain("formatJsonSource"); + }); + + it("string JSON editor open+save keeps duplicate members end-to-end", () => { + // Open: string JSON view pretty baseline comes from formatRedisMemberDetail. + const opened = formatRedisMemberDetail(DUPLICATE_MEMBER_COMPACT, { allowJsonText: true }); + expect(opened.json?.formattedText).toBe(DUPLICATE_MEMBER_PRETTY); + + // Save: saveString always compact-writes through normalizeRedisJsonDraft. + const saved = normalizeRedisJsonDraft(opened.json!.formattedText); + expect(saved).toEqual({ ok: true, compactText: DUPLICATE_MEMBER_COMPACT }); + + // Wiring: that compact text is what redisSetString receives. + const saveString = findFunction("saveString"); + expect(saveString.getText()).toContain("value = normalized.compactText"); + expect(callsIn(saveString).map(calledName)).toEqual(expect.arrayContaining(["normalizeRedisJsonDraft", "api.redisSetString"])); + }); + + it("hash JSON editor open+save keeps duplicate members end-to-end", () => { + // Open: hash field JSON view uses the same detail pretty baseline. + const opened = formatRedisMemberDetail(DUPLICATE_MEMBER_COMPACT, { allowJsonText: true }); + expect(opened.json?.formattedText).toBe(DUPLICATE_MEMBER_PRETTY); + + // Save: saveMemberEdit compact-writes hash JSON drafts through the same helper. + const saved = normalizeRedisJsonDraft(opened.json!.formattedText); + expect(saved).toEqual({ ok: true, compactText: DUPLICATE_MEMBER_COMPACT }); + + // Wiring: hash path only normalizes when savingHashJson, then HSETs writeValue. + const saveMemberEdit = findFunction("saveMemberEdit"); + const text = saveMemberEdit.getText(); + expect(text).toContain("if (savingHashJson)"); + expect(text).toContain("writeValue = normalized.compactText"); + expect(callsIn(saveMemberEdit).map(calledName)).toEqual(expect.arrayContaining(["normalizeRedisJsonDraft", "api.redisHashSet"])); + }); + + it("keeps the native editor and export paths on the RedisJSON value-text contract", () => { + const load = findFunction("load"); + const discard = findFunction("discardRedisJsonEdit"); + const exportStatements = findFunction("generateInsertStatements"); + const valueTextCalls = (node: ts.Node) => callsIn(node).filter((call) => calledName(call) === "redisJsonValueText"); + + expect(valueTextCalls(load).map((call) => call.arguments.map((argument) => argument.getText()))).toContainEqual(["loadedValue.data"]); + expect(valueTextCalls(exportStatements).map((call) => call.arguments.map((argument) => argument.getText()))).toContainEqual(["data.value.data"]); + expect(assignmentsIn(load).some((assignment) => assignment.left.getText() === "redisJsonDraftBaseline.value" && assignment.right.getText() === "jsonDraftForEditor(redisJsonValueText(loadedValue.data))")).toBe(true); + expect(assignmentsIn(discard).some((assignment) => assignment.left.getText() === "editValue.value" && assignment.right.getText() === "redisJsonDraftBaseline.value")).toBe(true); + + for (const node of [load, discard, exportStatements]) { + expect(callsIn(node).map(calledName)).not.toContain("JSON.stringify"); + } + expect(viewerSource).not.toContain("isRedisJsonDraftDirty"); + }); + + it("keeps retained drafts out of background refresh and exposes word wrap for every JSON editor", () => { + const autoRefresh = findFunction("startAutoRefresh"); + const hashSearch = findFunction("onHashSearch"); + const viewMember = findFunction("viewMember"); + const setMemberValueFormat = findFunction("setMemberValueFormat"); + const unsavedDraft = findVariableInitializer("hasUnsavedRedisDraft"); + const textFormat = findFunction("isTextRedisFormat"); + const labels = templateElements(parsedViewer.descriptor.template!.ast as unknown as TemplateElement); + const stringTextarea = findTemplateElement((element) => element.tag === "textarea" && directiveExpression(element, "model") === "editValue"); + const memberTextarea = findTemplateElement((element) => element.tag === "textarea" && directiveExpression(element, "model") === "memberEditValue"); + const refreshButton = findTemplateElement((element) => element.tag === "Button" && directiveExpression(element, "on", "click") === "load"); + + expect(autoRefresh.getText()).toContain("if (hasUnsavedRedisDraft.value) return;"); + expect(autoRefresh.getText()).toContain("load({ preserveDraft: true })"); + expect(hashSearch.getText()).toContain("if (!hasRetainedMemberDraft.value) clearSelectedMember();"); + expect(viewMember.getText()).toContain("hasRetainedMemberDraft.value"); + // Clean JSON → other format must clear memberDraftFormat so rawText is not compared to the pretty baseline. + expect(setMemberValueFormat.getText()).toContain("memberDraftFormat.value = null"); + expect(setMemberValueFormat.getText()).toContain('memberDraftFormat.value = "utf8"'); + expect(unsavedDraft.getText()).toContain("hasRetainedStringDraft.value"); + expect(unsavedDraft.getText()).toContain("hasRetainedMemberDraft.value"); + expect(textFormat.getText()).toContain('format === "json"'); + expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(stringValueView)")).toBe(true); + expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(memberValueView)")).toBe(true); + expect(directiveExpression(stringTextarea, "bind", "readonly")).toBe("!canEditCurrentStringFormat || savingString"); + expect(directiveExpression(memberTextarea, "bind", "readonly")).toBe("savingMember"); + expect(directiveExpression(refreshButton, "bind", "disabled")).toBe("hasUnsavedRedisDraft"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts b/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts index edeb7e419..bfbf2f80c 100644 --- a/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/redis/redisValuePresentation.spec.ts @@ -1,6 +1,19 @@ import { describe, expect, it } from "vitest"; -import { canRenderRedisValueFormat, formatRedisMemberDetail, formatRedisStringValue, getRedisMemberSelectionKey, preferredRedisValueFormat, redisMemberCopyText, sanitizeRedisDisplayText } from "@/lib/redis/redisValuePresentation"; +import { + canRenderRedisValueFormat, + formatRedisMemberDetail, + formatRedisStringValue, + getRedisMemberSelectionKey, + normalizeRedisJsonDraft, + preferredRedisValueFormat, + redisJsonValueText, + redisMemberCopyText, + redisValueCopyText, + redisValuePreview, + redisValueSize, + sanitizeRedisDisplayText, +} from "@/lib/redis/redisValuePresentation"; describe("redisValuePresentation", () => { it("strips control bytes from display without mutating raw member text", () => { @@ -34,6 +47,96 @@ describe("redisValuePresentation", () => { expect(formatRedisStringValue("plain-text")).toBe("plain-text"); }); + it("normalizes valid JSON drafts into compact Redis values", () => { + expect( + normalizeRedisJsonDraft(` + { + "name": "Ada", + "items": [1, 2, 3] + } + `), + ).toEqual({ ok: true, compactText: '{"name":"Ada","items":[1,2,3]}' }); + }); + + it("returns an invalid result instead of throwing for malformed JSON drafts", () => { + expect(normalizeRedisJsonDraft('{"name": }')).toEqual({ ok: false, error: "invalid_json" }); + }); + + it("keeps lossless large and high-precision numbers when normalizing drafts", () => { + const compact = '{"id":87712409002717401,"fraction":0.123456789012345678901234,"scientific":1.234567890123456789e20}'; + const formatted = `{ + "id": 87712409002717401, + "fraction": 0.123456789012345678901234, + "scientific": 1.234567890123456789e20 + }`; + + expect(normalizeRedisJsonDraft(formatted)).toEqual({ ok: true, compactText: compact }); + }); + + // Reviewer fixture: Redis string/hash values are raw text, so open+save must + // only strip insignificant whitespace and must keep both "role" members. + const DUPLICATE_MEMBER_COMPACT = '{"role":"reader","role":"writer"}'; + const DUPLICATE_MEMBER_PRETTY = `{ + "role": "reader", + "role": "writer" +}`; + + it("string JSON editor open+save keeps duplicate object members", () => { + // Open string key JSON view → pretty baseline from raw Redis text. + const stringDetail = formatRedisMemberDetail(DUPLICATE_MEMBER_COMPACT, { allowJsonText: true }); + expect(stringDetail.json).toBeDefined(); + expect(stringDetail.json?.rawText).toBe(DUPLICATE_MEMBER_COMPACT); + expect(stringDetail.json?.formattedText).toBe(DUPLICATE_MEMBER_PRETTY); + + // Save path compact-writes the editor draft (pretty baseline, no user edit). + expect(normalizeRedisJsonDraft(stringDetail.json!.formattedText)).toEqual({ + ok: true, + compactText: DUPLICATE_MEMBER_COMPACT, + }); + // Re-saving an already-compact draft must also keep both members. + expect(normalizeRedisJsonDraft(DUPLICATE_MEMBER_COMPACT)).toEqual({ + ok: true, + compactText: DUPLICATE_MEMBER_COMPACT, + }); + }); + + it("hash field JSON editor open+save keeps duplicate object members", () => { + // Hash fields reuse the same presentation/normalize helpers as string keys. + const hashFieldDetail = formatRedisMemberDetail(DUPLICATE_MEMBER_COMPACT, { allowJsonText: true }); + expect(hashFieldDetail.availableFormats).toContain("json"); + expect(hashFieldDetail.json?.formattedText).toBe(DUPLICATE_MEMBER_PRETTY); + + // Hash saveMemberEdit compact-writes through normalizeRedisJsonDraft. + expect(normalizeRedisJsonDraft(hashFieldDetail.json!.formattedText)).toEqual({ + ok: true, + compactText: DUPLICATE_MEMBER_COMPACT, + }); + expect(normalizeRedisJsonDraft(DUPLICATE_MEMBER_PRETTY)).toEqual({ + ok: true, + compactText: DUPLICATE_MEMBER_COMPACT, + }); + }); + + it("keeps native RedisJSON source text lossless for copy, preview, and size", () => { + const rawText = '{"id":2326645729978441729,"fraction":0.123456789012345678901234,"scientific":1.234567890123456789e20}'; + const value = { + key_display: "json:profile", + key_raw: "json:profile", + ttl: -1, + redis_type: "ReJSON-RL", + data: { kind: "json" as const, value: rawText }, + }; + + expect(redisJsonValueText(value.data)).toBe(rawText); + expect(redisValuePreview(value)).toBe(rawText); + expect(redisValueSize(value)).toBe(new TextEncoder().encode(rawText).byteLength); + expect(redisValueCopyText(value)).toBe(`{ + "id": 2326645729978441729, + "fraction": 0.123456789012345678901234, + "scientific": 1.234567890123456789e20 +}`); + }); + it("labels raw text views by encoding instead of generic raw text", () => { expect(formatRedisMemberDetail("plain-text").rawLabel).toBe("ASCII"); expect( diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 9eee39be2..0233bf394 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1466,7 +1466,7 @@ export interface RedisStreamEntry { export type RedisValueData = | { kind: "string"; content: RedisBlob } - | { kind: "json"; value: unknown } + | { kind: "json"; value: string } | { kind: "list"; items: RedisListItem[]; total: number; scan_cursor?: number } | { kind: "set"; items: RedisSetItem[]; total: number; scan_cursor?: number } | { kind: "hash"; items: RedisHashItem[]; total: number; scan_cursor?: number } diff --git a/apps/desktop/src/lib/common/__tests__/safeJsonFormat.spec.ts b/apps/desktop/src/lib/common/__tests__/safeJsonFormat.spec.ts index bbe77f5f4..92315c813 100644 --- a/apps/desktop/src/lib/common/__tests__/safeJsonFormat.spec.ts +++ b/apps/desktop/src/lib/common/__tests__/safeJsonFormat.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers, safeJsonFormat } from "../safeJsonFormat"; +import { formatJsonSource, isLosslessJsonNumber, parseJsonPreservingLargeNumbers, safeJsonFormat } from "../safeJsonFormat"; describe("safeJsonFormat", () => { it("preserves large integers exceeding MAX_SAFE_INTEGER", () => { @@ -86,3 +86,63 @@ describe("safeJsonFormat", () => { expect(safeJsonFormat(input, 2)).toContain("1e999"); }); }); + +describe("formatJsonSource", () => { + it("minifies by removing only insignificant whitespace", () => { + const input = `{ + "name": "Ada", + "items": [1, 2, 3] + }`; + expect(formatJsonSource(input)).toBe('{"name":"Ada","items":[1,2,3]}'); + }); + + it("pretty-prints with the requested indent while keeping source tokens", () => { + expect(formatJsonSource('{"role":"reader","role":"writer"}', 2)).toBe(`{ + "role": "reader", + "role": "writer" +}`); + }); + + it("preserves duplicate object members when minifying", () => { + const pretty = `{ + "role": "reader", + "role": "writer" +}`; + expect(formatJsonSource(pretty)).toBe('{"role":"reader","role":"writer"}'); + }); + + it("preserves key order when duplicate members are mixed with other keys", () => { + const input = '{"a":1,"role":"reader","b":2,"role":"writer","c":3}'; + expect(formatJsonSource(input)).toBe(input); + expect(formatJsonSource(input, 2)).toBe(`{ + "a": 1, + "role": "reader", + "b": 2, + "role": "writer", + "c": 3 +}`); + }); + + it("preserves large and high-precision number literals", () => { + const compact = '{"id":87712409002717401,"fraction":0.123456789012345678901234,"scientific":1.234567890123456789e20}'; + const pretty = `{ + "id": 87712409002717401, + "fraction": 0.123456789012345678901234, + "scientific": 1.234567890123456789e20 +}`; + + expect(formatJsonSource(pretty)).toBe(compact); + expect(formatJsonSource(compact, 2)).toBe(pretty); + }); + + it("preserves string escape sequences and does not rewrite them", () => { + const input = '{"path":"C:\\\\Users\\\\path","quote":"say \\"hi\\"","unicode":"\\u4e2d"}'; + expect(formatJsonSource(input)).toBe(input); + }); + + it("rejects invalid JSON", () => { + expect(() => formatJsonSource('{"name": }')).toThrow(SyntaxError); + expect(() => formatJsonSource('{"a":1,}')).toThrow(SyntaxError); + expect(() => formatJsonSource('{"a":1} trailing')).toThrow(SyntaxError); + }); +}); diff --git a/apps/desktop/src/lib/common/safeJsonFormat.ts b/apps/desktop/src/lib/common/safeJsonFormat.ts index 7721d79b8..5836bf9c1 100644 --- a/apps/desktop/src/lib/common/safeJsonFormat.ts +++ b/apps/desktop/src/lib/common/safeJsonFormat.ts @@ -30,6 +30,9 @@ export function parseJsonPreservingLargeNumbers(text: string): unknown { * Parse and re-stringify JSON while preserving numeric literals whose integer * parts exceed Number.MAX_SAFE_INTEGER (2^53 - 1), plus decimal and exponent * forms that JavaScript may round or turn into Infinity. + * + * Note: this rebuilds a JS object, so duplicate object members are collapsed. + * Prefer {@link formatJsonSource} when the source text itself must stay lossless. */ export function safeJsonFormat(text: string, indent?: number): string { const protectedJson = protectLargeJsonNumbers(text); @@ -43,6 +46,20 @@ export function safeJsonFormat(text: string, indent?: number): string { return result; } +/** + * Validate JSON and re-emit source tokens while only changing insignificant + * whitespace. Unlike {@link safeJsonFormat}, this keeps duplicate object + * members, key order, string escapes, and number spellings intact. + */ +export function formatJsonSource(text: string, indent?: number): string { + const scanner = new JsonSourceScanner(text); + const writer = new JsonSourceWriter(indent); + writeJsonValue(scanner, writer, 0); + scanner.skipWhitespace(); + if (!scanner.eof()) throw new SyntaxError(`Unexpected trailing content in JSON at position ${scanner.position}`); + return writer.toString(); +} + function protectLargeJsonNumbers(text: string): ProtectedJsonNumbers { let placeholderPrefix = "__DBX_LOSSLESS_NUMBER_"; while (text.includes(placeholderPrefix)) placeholderPrefix += "_"; @@ -120,3 +137,267 @@ function restoreLosslessNumbers(value: unknown, numbers: Map): u } return value; } + +class JsonSourceScanner { + readonly text: string; + position = 0; + + constructor(text: string) { + this.text = text; + } + + eof(): boolean { + return this.position >= this.text.length; + } + + peek(): string | undefined { + return this.text[this.position]; + } + + skipWhitespace() { + while (this.position < this.text.length) { + const character = this.text[this.position]; + if (character === " " || character === "\t" || character === "\n" || character === "\r") { + this.position += 1; + continue; + } + break; + } + } + + expect(character: string) { + this.skipWhitespace(); + if (this.text[this.position] !== character) { + throw new SyntaxError(`Expected '${character}' at position ${this.position}`); + } + this.position += 1; + } + + readStringToken(): string { + this.skipWhitespace(); + if (this.text[this.position] !== '"') { + throw new SyntaxError(`Expected string at position ${this.position}`); + } + + const start = this.position; + this.position += 1; + let escaped = false; + while (this.position < this.text.length) { + const character = this.text[this.position]; + if (escaped) { + if (character === "u") { + const hex = this.text.slice(this.position + 1, this.position + 5); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) { + throw new SyntaxError(`Invalid unicode escape at position ${this.position}`); + } + this.position += 5; + } else if (!'"\\/bfnrt'.includes(character)) { + throw new SyntaxError(`Invalid escape sequence at position ${this.position}`); + } else { + this.position += 1; + } + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + this.position += 1; + continue; + } + if (character === '"') { + this.position += 1; + return this.text.slice(start, this.position); + } + // JSON strings cannot contain raw control characters. + if (character.charCodeAt(0) < 0x20) { + throw new SyntaxError(`Invalid control character in string at position ${this.position}`); + } + this.position += 1; + } + throw new SyntaxError(`Unterminated string at position ${start}`); + } + + readNumberToken(): string { + this.skipWhitespace(); + const start = this.position; + if (this.text[this.position] === "-") this.position += 1; + + if (this.text[this.position] === "0") { + this.position += 1; + } else if (this.isDigit(this.text[this.position])) { + while (this.isDigit(this.text[this.position])) this.position += 1; + } else { + throw new SyntaxError(`Invalid number at position ${start}`); + } + + if (this.text[this.position] === ".") { + this.position += 1; + if (!this.isDigit(this.text[this.position])) { + throw new SyntaxError(`Invalid number at position ${start}`); + } + while (this.isDigit(this.text[this.position])) this.position += 1; + } + + if (this.text[this.position] === "e" || this.text[this.position] === "E") { + this.position += 1; + if (this.text[this.position] === "+" || this.text[this.position] === "-") this.position += 1; + if (!this.isDigit(this.text[this.position])) { + throw new SyntaxError(`Invalid number at position ${start}`); + } + while (this.isDigit(this.text[this.position])) this.position += 1; + } + + if (this.position === start || (this.position === start + 1 && this.text[start] === "-")) { + throw new SyntaxError(`Invalid number at position ${start}`); + } + return this.text.slice(start, this.position); + } + + readKeywordToken(keyword: "true" | "false" | "null"): string { + this.skipWhitespace(); + if (this.text.slice(this.position, this.position + keyword.length) !== keyword) { + throw new SyntaxError(`Expected '${keyword}' at position ${this.position}`); + } + this.position += keyword.length; + return keyword; + } + + private isDigit(character: string | undefined): boolean { + return character !== undefined && character >= "0" && character <= "9"; + } +} + +class JsonSourceWriter { + private readonly chunks: string[] = []; + private readonly indentSize: number | undefined; + private readonly pretty: boolean; + + constructor(indent?: number) { + this.indentSize = indent !== undefined && indent > 0 ? indent : undefined; + this.pretty = this.indentSize !== undefined; + } + + write(text: string) { + this.chunks.push(text); + } + + newline(depth: number) { + if (!this.pretty || this.indentSize === undefined) return; + this.chunks.push("\n" + " ".repeat(this.indentSize * depth)); + } + + space() { + if (this.pretty) this.chunks.push(" "); + } + + toString(): string { + return this.chunks.join(""); + } +} + +function writeJsonValue(scanner: JsonSourceScanner, writer: JsonSourceWriter, depth: number) { + scanner.skipWhitespace(); + const character = scanner.peek(); + if (character === undefined) throw new SyntaxError("Unexpected end of JSON input"); + + if (character === "{") { + writeJsonObject(scanner, writer, depth); + return; + } + if (character === "[") { + writeJsonArray(scanner, writer, depth); + return; + } + if (character === '"') { + writer.write(scanner.readStringToken()); + return; + } + if (character === "-" || (character >= "0" && character <= "9")) { + writer.write(scanner.readNumberToken()); + return; + } + if (character === "t") { + writer.write(scanner.readKeywordToken("true")); + return; + } + if (character === "f") { + writer.write(scanner.readKeywordToken("false")); + return; + } + if (character === "n") { + writer.write(scanner.readKeywordToken("null")); + return; + } + + throw new SyntaxError(`Unexpected token '${character}' at position ${scanner.position}`); +} + +function writeJsonObject(scanner: JsonSourceScanner, writer: JsonSourceWriter, depth: number) { + scanner.expect("{"); + writer.write("{"); + scanner.skipWhitespace(); + + if (scanner.peek() === "}") { + scanner.position += 1; + writer.write("}"); + return; + } + + let first = true; + while (true) { + if (!first) { + scanner.expect(","); + writer.write(","); + } + first = false; + writer.newline(depth + 1); + writer.write(scanner.readStringToken()); + scanner.expect(":"); + writer.write(":"); + writer.space(); + writeJsonValue(scanner, writer, depth + 1); + scanner.skipWhitespace(); + if (scanner.peek() === "}") { + scanner.position += 1; + writer.newline(depth); + writer.write("}"); + return; + } + if (scanner.peek() !== ",") { + throw new SyntaxError(`Expected ',' or '}' in object at position ${scanner.position}`); + } + } +} + +function writeJsonArray(scanner: JsonSourceScanner, writer: JsonSourceWriter, depth: number) { + scanner.expect("["); + writer.write("["); + scanner.skipWhitespace(); + + if (scanner.peek() === "]") { + scanner.position += 1; + writer.write("]"); + return; + } + + let first = true; + while (true) { + if (!first) { + scanner.expect(","); + writer.write(","); + } + first = false; + writer.newline(depth + 1); + writeJsonValue(scanner, writer, depth + 1); + scanner.skipWhitespace(); + if (scanner.peek() === "]") { + scanner.position += 1; + writer.newline(depth); + writer.write("]"); + return; + } + if (scanner.peek() !== ",") { + throw new SyntaxError(`Expected ',' or ']' in array at position ${scanner.position}`); + } + } +} diff --git a/apps/desktop/src/lib/redis/redisValuePresentation.ts b/apps/desktop/src/lib/redis/redisValuePresentation.ts index 1d5bfd75e..4ca899637 100644 --- a/apps/desktop/src/lib/redis/redisValuePresentation.ts +++ b/apps/desktop/src/lib/redis/redisValuePresentation.ts @@ -1,6 +1,6 @@ import type { BinaryHexViewRow } from "@/lib/dataGrid/binaryHexViewer"; import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer"; -import { parseJsonPreservingLargeNumbers, safeJsonFormat } from "@/lib/common/safeJsonFormat"; +import { formatJsonSource, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat"; import type { RedisBlob, RedisCollectionPage, RedisHashItem, RedisListItem, RedisSetItem, RedisValue, RedisZsetItem } from "@/lib/backend/api"; import { parseJavaSerializedDetail, type RedisJavaSerializedDetail } from "@/lib/redis/javaSerialized"; @@ -34,6 +34,16 @@ export interface RedisJsonDetail { value: unknown; } +export type RedisJsonDraftNormalizationResult = + | { + ok: true; + compactText: string; + } + | { + ok: false; + error: "invalid_json"; + }; + export interface RedisMemberDetailOptions { allowJsonText?: boolean; } @@ -155,6 +165,11 @@ export function formatRedisCommandResult(value: unknown): string { return JSON.stringify(value, null, 2); } +/** RedisJSON source text stays out of JavaScript's numeric representation. */ +export function redisJsonValueText(value: { value: string }): string { + return value.value; +} + /** * Detect if a value is a cluster-aggregated INFO response: * `[[addr, infoText], ...]` where each `infoText` starts with `"# "` @@ -189,10 +204,13 @@ export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null { if (!trimmed) return null; try { + // Pretty-print from source tokens so duplicate members and number spellings + // stay intact when Redis string/hash values open in the JSON editor. + const formattedText = formatJsonSource(trimmed, 2); const parsed = parseJsonPreservingLargeNumbers(trimmed); return { rawText: value, - formattedText: safeJsonFormat(trimmed, 2), + formattedText, value: parsed, }; } catch { @@ -200,6 +218,19 @@ export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null { } } +/** + * Validates a JSON editor draft and produces the compact text Redis should + * store. Source-preserving minification keeps high-precision numbers and + * duplicate object members intact. + */ +export function normalizeRedisJsonDraft(text: string): RedisJsonDraftNormalizationResult { + try { + return { ok: true, compactText: formatJsonSource(text) }; + } catch { + return { ok: false, error: "invalid_json" }; + } +} + export function preferredRedisValueFormat(value: unknown, preferred?: RedisValueFormat | null, options: RedisMemberDetailOptions = {}): RedisValueFormat { const detail = formatRedisMemberDetail(value, options); if (preferred && detail.availableFormats.includes(preferred) && shouldReuseRedisValueFormatPreference(detail, preferred)) return preferred; @@ -272,7 +303,7 @@ export function redisValueSize(value: RedisValue): number { case "string": return decodeRedisBlob(value.data.content).byteLength; case "json": - return new TextEncoder().encode(JSON.stringify(value.data.value)).byteLength; + return new TextEncoder().encode(redisJsonValueText(value.data)).byteLength; case "list": case "set": case "hash": @@ -290,7 +321,7 @@ export function redisValuePreview(value: RedisValue): string { case "string": return previewText(redisBlobRawText(value.data.content)); case "json": - return previewText(JSON.stringify(value.data.value)); + return previewText(redisJsonValueText(value.data)); case "list": { const first = value.data.items[0]; return first ? previewText(redisBlobRawText(first.value)) : ""; @@ -322,8 +353,14 @@ export function redisValueCopyText(value: RedisValue, collectionItems: RedisColl switch (value.data.kind) { case "string": return redisBlobRawText(value.data.content); - case "json": - return JSON.stringify(value.data.value, null, 2); + case "json": { + const rawText = redisJsonValueText(value.data); + try { + return formatJsonSource(rawText, 2); + } catch { + return rawText; + } + } case "list": return JSON.stringify( (collectionItems as RedisListItem[]).map((item) => redisBlobRawText(item.value)), diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index cf9e4fe9b..bb3ef698f 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -124,7 +124,8 @@ pub enum RedisValueData { content: RedisBlob, }, Json { - value: serde_json::Value, + /// Original JSON.GET payload used throughout the RedisJSON UI. + value: String, }, List { items: Vec, @@ -1403,27 +1404,20 @@ pub fn is_redis_json_type(key_type: &str) -> bool { matches!(key_type.to_ascii_uppercase().as_str(), "REJSON-RL" | "JSON") } -pub fn redis_json_raw_to_json(value: RedisRawValue) -> Result { - match redis_raw_to_json(value) { - serde_json::Value::Null => Ok(serde_json::Value::Null), - serde_json::Value::String(text) => { - serde_json::from_str(&text).map(json_value_for_js).map_err(|e| format!("Invalid RedisJSON value: {e}")) +pub fn redis_json_raw_to_text(value: RedisRawValue) -> Result { + match value { + RedisRawValue::BulkString(bytes) => { + String::from_utf8(bytes).map_err(|e| format!("RedisJSON value is not valid UTF-8: {e}")) } - other => Ok(json_value_for_js(other)), + RedisRawValue::SimpleString(text) => Ok(text), + RedisRawValue::VerbatimString { text, .. } => Ok(text), + // Do not turn a key deleted between TYPE and JSON.GET into an editable + // JSON null; saving that draft would recreate a phantom key. + RedisRawValue::Nil => Err("RedisJSON key no longer exists".to_string()), + other => Err(format!("Unexpected RedisJSON response: {other:?}")), } } -pub fn redis_json_value_preview(value: &serde_json::Value) -> String { - const MAX_PREVIEW_LEN: usize = 160; - let text = serde_json::to_string(value).unwrap_or_else(|_| value.to_string()); - if text.chars().count() <= MAX_PREVIEW_LEN { - return text; - } - let mut preview = text.chars().take(MAX_PREVIEW_LEN).collect::(); - preview.push('…'); - preview -} - pub fn redis_key_value_preview(key_type: &str) -> String { if is_redis_json_type(key_type) { "{...}".to_string() @@ -1894,7 +1888,7 @@ where key_type if is_redis_json_type(key_type) => { let raw: RedisRawValue = redis::cmd("JSON.GET").arg(key).query_async(con).await.map_err(|e| e.to_string())?; - RedisValueData::Json { value: redis_json_raw_to_json(raw)? } + RedisValueData::Json { value: redis_json_raw_to_text(raw)? } } _ => RedisValueData::Unknown, }; @@ -1928,7 +1922,7 @@ fn redis_key_matches_query(key_display: &str, key_raw: &str, query: &str) -> boo fn redis_search_value_text(value: &RedisValueData) -> String { match value { RedisValueData::String { content } => redis_blob_display_text(content), - RedisValueData::Json { value } => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()), + RedisValueData::Json { value } => value.clone(), RedisValueData::List { items, .. } => { items.iter().map(|item| redis_blob_display_text(&item.value)).collect::>().join(" ") } @@ -1973,7 +1967,7 @@ fn redis_search_value_size(value: &RedisValue) -> u64 { .decode(&content.raw_base64) .map(|bytes| bytes.len() as u64) .unwrap_or(0), - RedisValueData::Json { value } => serde_json::to_vec(value).map(|bytes| bytes.len() as u64).unwrap_or(0), + RedisValueData::Json { value } => value.len() as u64, RedisValueData::List { total, .. } | RedisValueData::Set { total, .. } | RedisValueData::Hash { total, .. } @@ -2118,14 +2112,6 @@ fn redis_list_items_from_raw(value: RedisRawValue, start_index: u64) -> Vec serde_json::Value { - match value { - RedisRawValue::Nil => serde_json::Value::Null, - RedisRawValue::Array(values) => serde_json::Value::Array(values.into_iter().map(redis_raw_to_json).collect()), - other => serde_json::Value::String(redis_value_to_string(other).unwrap_or_default()), - } -} - fn redis_bytes_to_display(bytes: &[u8]) -> String { if let Ok(text) = std::str::from_utf8(bytes) { return text.replace('\\', "\\\\"); @@ -2597,12 +2583,11 @@ mod tests { classify_command, connection_info, decode_cluster_cursor, encode_cluster_cursor, is_redis_json_type, parse_cluster_slots, parse_command_argv, parse_database_count, parse_redis_endpoint, parse_scan_keys, parse_stream_entries, redis_auth_candidates, redis_blob_from_bytes, redis_cluster_slot, - redis_command_raw_to_json, redis_database_index, redis_json_raw_to_json, redis_json_value_preview, - redis_key_bytes_to_display, redis_key_bytes_to_raw, redis_key_matches_query, redis_key_raw_to_bytes, - redis_key_value_preview, redis_raw_to_json, redis_sentinel_master_endpoint, redis_value_matches_query, - redis_value_to_bytes, RedisAuthCandidate, RedisBlob, RedisBlobEncoding, RedisClusterSlotRange, - RedisCollectionPage, RedisCommandSafety, RedisHashItem, RedisNodeEndpoint, RedisRawValue, RedisSetItem, - RedisStreamEntry, RedisStreamField, RedisValue, RedisValueData, + redis_command_raw_to_json, redis_database_index, redis_key_bytes_to_display, redis_key_bytes_to_raw, + redis_key_matches_query, redis_key_raw_to_bytes, redis_key_value_preview, redis_sentinel_master_endpoint, + redis_value_matches_query, redis_value_to_bytes, RedisAuthCandidate, RedisBlob, RedisBlobEncoding, + RedisClusterSlotRange, RedisCollectionPage, RedisCommandSafety, RedisHashItem, RedisNodeEndpoint, + RedisRawValue, RedisSetItem, RedisStreamEntry, RedisStreamField, RedisValue, RedisValueData, }; use crate::models::connection::ConnectionConfig; use redis::{aio::ConnectionLike, Cmd, ConnectionAddr, Pipeline, RedisFuture}; @@ -2963,15 +2948,6 @@ mod tests { assert_eq!(con.command_count("HSCAN"), super::HASH_FILTER_SCAN_MAX_ITERATIONS); } - #[test] - fn formats_binary_string_values_like_rdm() { - let raw = RedisRawValue::BulkString(vec![0xAC, 0xED, 0x00, 0x05, b's', b'r']); - - let value = redis_raw_to_json(raw); - - assert_eq!(value, serde_json::Value::String("\\xac\\xed\\x00\\x05sr".to_string())); - } - #[test] fn does_not_treat_utf8_with_backslashes_as_binary() { let raw = RedisRawValue::BulkString(br#"C:\Users\path"#.to_vec()); @@ -3290,37 +3266,50 @@ mod tests { } #[test] - fn parses_redis_json_get_bulk_string() { - let raw = bulk(r#"{"id":1,"embedding":[0.1,0.2],"meta":{"source":"test"}}"#); + fn preserves_raw_redis_json_text_without_parsing_numbers_for_js() { + let raw_text = + r#"{"id":2326645729978441729,"fraction":0.123456789012345678901234,"scientific":1.234567890123456789e20}"#; - assert_eq!( - redis_json_raw_to_json(raw).unwrap(), - serde_json::json!({ - "id": 1, - "embedding": [0.1, 0.2], - "meta": { "source": "test" } - }) - ); + assert_eq!(super::redis_json_raw_to_text(bulk(raw_text)).unwrap(), raw_text); + assert_eq!(super::redis_json_raw_to_text(RedisRawValue::Nil).unwrap_err(), "RedisJSON key no longer exists"); + assert!(super::redis_json_raw_to_text(RedisRawValue::BulkString(vec![0xff])).is_err()); } - #[test] - fn parses_redis_json_unsafe_int64_as_string_for_js() { - let raw = bulk(r#"{"id":2326645729978441729,"nested":[1,2326645729978441728]}"#); + #[tokio::test] + async fn returns_lossless_value_text_for_native_redis_json_values() { + let value_text = r#"{"id":2326645729978441729,"fraction":0.123456789012345678901234,"name":"Ada"}"#; + let mut con = FakeRedisConnection::new(vec![bulk("ReJSON-RL"), RedisRawValue::Int(-1), bulk(value_text)]); - assert_eq!( - redis_json_raw_to_json(raw).unwrap(), - serde_json::json!({ - "id": "2326645729978441729", - "nested": [1, "2326645729978441728"] - }) - ); + let value = super::get_value(&mut con, b"json:key").await.unwrap(); + let response = serde_json::to_value(&value).unwrap(); + let RedisValueData::Json { value: returned } = value.data else { + panic!("expected RedisJSON value"); + }; + assert_eq!(returned, value_text); + assert_eq!(con.command_count("JSON.GET"), 1); + + assert_eq!(response["data"]["value"], value_text); + assert!(response["data"].get("raw_text").is_none()); } - #[test] - fn builds_compact_redis_json_value_preview() { - let value = serde_json::json!({ "id": 1, "embedding": [0.1, 0.2] }); + #[tokio::test] + async fn rejects_a_native_redis_json_key_deleted_after_type_lookup() { + let mut con = FakeRedisConnection::new(vec![bulk("ReJSON-RL"), RedisRawValue::Int(-1), RedisRawValue::Nil]); - assert_eq!(redis_json_value_preview(&value), r#"{"id":1,"embedding":[0.1,0.2]}"#); + let error = super::get_value(&mut con, b"json:key").await.unwrap_err(); + + assert_eq!(error, "RedisJSON key no longer exists"); + } + + #[tokio::test] + async fn redis_json_set_keeps_lossless_numeric_literals_in_the_command() { + let raw_text = r#"{"id":2326645729978441729,"fraction":0.123456789012345678901234}"#; + let mut con = FakeRedisConnection::new(vec![RedisRawValue::Okay]); + + super::json_set(&mut con, b"json:key", raw_text, None).await.unwrap(); + + assert_eq!(con.command_count("JSON.SET"), 1); + assert!(con.commands[0].contains(raw_text)); } #[test]
{{ detailTextForFormat(stringValueDetail, stringValueView) }}