feat(redis): edit JSON values as source with compact saves
This commit is contained in:
parent
deb4cbfdfe
commit
ceadf73e4b
|
|
@ -0,0 +1,72 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useCellDetailEditor } from "@/composables/useCellDetailEditor";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { isSaveShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
defineOptions({ name: "RedisJsonEditor" });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
/** Save shortcuts are still consumed while disabled so global handlers cannot act on them. */
|
||||
saveDisabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
wordWrap?: boolean;
|
||||
}>(),
|
||||
{
|
||||
saveDisabled: false,
|
||||
readOnly: false,
|
||||
wordWrap: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
save: [];
|
||||
}>();
|
||||
|
||||
const editorContainer = ref<HTMLElement>();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { isDark, themePalette } = useTheme();
|
||||
|
||||
const editor = useCellDetailEditor({
|
||||
language: "json",
|
||||
lineNumbers: true,
|
||||
folding: true,
|
||||
lineWrapping: () => props.wordWrap,
|
||||
readOnly: () => props.readOnly,
|
||||
onChange(value) {
|
||||
emit("update:modelValue", value);
|
||||
},
|
||||
onSaveShortcut(event) {
|
||||
if (!isSaveShortcut(event, settingsStore.editorSettings.shortcuts)) return false;
|
||||
if (!props.saveDisabled) emit("save");
|
||||
return true;
|
||||
},
|
||||
editorTheme: () => settingsStore.editorSettings.theme,
|
||||
appAppearance: () => (isDark.value ? "dark" : "light"),
|
||||
appPalette: () => themePalette.value,
|
||||
fontSize: () => settingsStore.editorSettings.fontSize,
|
||||
fontFamily: () => settingsStore.editorSettings.fontFamily,
|
||||
});
|
||||
|
||||
watch(editorContainer, async (container) => {
|
||||
if (!container) return;
|
||||
await editor.create(container, props.modelValue, "json");
|
||||
if (editor.getValue() !== props.modelValue) editor.setValue(props.modelValue, "json");
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
// Do not reset the cursor after this editor emitted a normal v-model update.
|
||||
if (editor.getValue() !== value) editor.setValue(value, "json");
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="editorContainer" class="h-full min-h-0 w-full" data-redis-json-editor />
|
||||
</template>
|
||||
|
|
@ -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<RedisValue | null>(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<RedisValueFormat>(readPreferredRedisValueFormat());
|
||||
const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
|
||||
const redisJsonView = ref<"raw" | "tree">("raw");
|
||||
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
|
||||
const redisJsonHighlighter = ref<JsonHighlighter>();
|
||||
|
||||
|
|
@ -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<RedisBlob | 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 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<boolean> {
|
||||
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(() => {
|
|||
<div class="shrink-0 border-b bg-background">
|
||||
<div class="flex h-9 items-center gap-2 px-4">
|
||||
<span class="dbx-editor-font-family min-w-0 flex-1 truncate text-sm font-semibold">{{ formatValue(data.key_display) }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 animate-none" @click="load"><RefreshCw class="h-3.5 w-3.5 animate-none" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 animate-none" :disabled="hasUnsavedRedisDraft" @click="load"><RefreshCw class="h-3.5 w-3.5 animate-none" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('redis.copyInsertStatement')" @click="copyInsertStatement"><Terminal class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-destructive" @click="requestDeleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
|
|
@ -1195,9 +1309,7 @@ onBeforeUnmount(() => {
|
|||
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="stringValueView === 'json' && stringValueDetail.json" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
|
||||
<JsonTree :value="stringValueDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<RedisJsonEditor v-if="stringValueView === 'json' && stringValueDetail.json" v-model="editValue" class="min-h-0 flex-1" :save-disabled="savingString || !stringValueChanged" :read-only="savingString" :word-wrap="redisJsonWordWrap" @save="saveString" />
|
||||
<div v-else-if="stringValueView === 'javaserialize' && stringValueDetail.javaSerialized" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
|
||||
<JsonTree :value="stringValueDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
<pre v-else-if="stringValueView === 'utf8'" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6" :class="detailTextClass(stringValueView)">{{ detailTextForFormat(stringValueDetail, stringValueView) }}</pre>
|
||||
<pre v-else class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6" :class="detailTextClass(stringValueView)">{{ detailTextForFormat(stringValueDetail, stringValueView) }}</pre>
|
||||
|
|
@ -1225,50 +1336,25 @@ onBeforeUnmount(() => {
|
|||
{{ t("redis.binaryStringReadonlyHint") }}
|
||||
</div>
|
||||
<div v-if="showStringEditActions" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
|
||||
<Button variant="ghost" size="sm" :disabled="!stringValueChanged" @click="discardStringEdit">{{ t("grid.discard") }}</Button>
|
||||
<Button size="sm" :disabled="!stringValueChanged" @click="saveString"><Save class="w-3 h-3 mr-1" /> {{ t("grid.save") }}</Button>
|
||||
<Button variant="ghost" size="sm" :disabled="savingString || !stringValueChanged" @click="discardStringEdit">{{ t("grid.discard") }}</Button>
|
||||
<Button size="sm" :disabled="savingString || !stringValueChanged" @click="saveString"><Loader2 v-if="savingString" class="w-3 h-3 mr-1 animate-spin" /><Save v-else class="w-3 h-3 mr-1" /> {{ t("grid.save") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Redis JSON -->
|
||||
<div v-else-if="redisKind === 'json' && redisJsonValue != null" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div v-else-if="redisKind === 'json'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex h-9 items-center gap-2 border-b px-4 text-xs shrink-0">
|
||||
<div class="flex overflow-hidden rounded-md border bg-muted/20 p-0.5">
|
||||
<Button variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': redisJsonView === 'raw' }" @click="redisJsonView = 'raw'">
|
||||
{{ t("redis.rawContent") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': redisJsonView === 'tree' }" @click="redisJsonView = 'tree'">
|
||||
{{ t("redis.jsonView") }}
|
||||
</Button>
|
||||
</div>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="redisJsonView === 'raw'" variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :title="t('redis.formatJson')" @click="handleFormatJsonEditor">
|
||||
<IndentIncrease class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button v-if="redisJsonView === 'raw'" variant="ghost" size="sm" class="h-6 rounded-[5px] px-2 text-xs" :title="t('redis.compressJson')" @click="handleCompressJsonEditor">
|
||||
<IndentDecrease class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<label v-if="redisJsonView === 'raw'" class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<label class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WrapText class="h-3.5 w-3.5" />
|
||||
{{ t("redis.wordWrap") }}
|
||||
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="redisJsonView === 'tree'" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
|
||||
<JsonTree :value="redisJsonValue" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<textarea v-else 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'" spellcheck="false" @input="handleJsonInput" />
|
||||
<div v-if="isEditing" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="
|
||||
isEditing = false;
|
||||
editValue = JSON.stringify(redisJsonValue, null, 2);
|
||||
"
|
||||
>{{ t("grid.discard") }}</Button
|
||||
>
|
||||
<Button size="sm" @click="saveJson"><Save class="w-3 h-3 mr-1" /> {{ t("grid.save") }}</Button>
|
||||
<RedisJsonEditor v-model="editValue" class="min-h-0 flex-1" :save-disabled="savingJson || !redisJsonValueChanged" :read-only="savingJson" :word-wrap="redisJsonWordWrap" @save="saveJson" />
|
||||
<div v-if="redisJsonValueChanged" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
|
||||
<Button variant="ghost" size="sm" :disabled="savingJson" @click="discardRedisJsonEdit">{{ t("grid.discard") }}</Button>
|
||||
<Button size="sm" :disabled="savingJson" @click="saveJson"><Loader2 v-if="savingJson" class="w-3 h-3 mr-1 animate-spin" /><Save v-else class="w-3 h-3 mr-1" /> {{ t("grid.save") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1548,7 +1634,7 @@ onBeforeUnmount(() => {
|
|||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<template v-if="isEditingMember">
|
||||
<textarea v-model="memberEditValue" class="dbx-editor-font-family min-h-0 flex-1 resize-none bg-background p-5 text-[13px] leading-6 outline-none" spellcheck="false" />
|
||||
<textarea v-model="memberEditValue" class="dbx-editor-font-family min-h-0 flex-1 resize-none bg-background p-5 text-[13px] leading-6 outline-none" :readonly="savingMember" spellcheck="false" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex h-9 items-center gap-2 border-b px-5 text-xs">
|
||||
|
|
@ -1573,7 +1659,8 @@ onBeforeUnmount(() => {
|
|||
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="memberValueView === 'json' && selectedMemberDetail.json" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
|
||||
<RedisJsonEditor v-if="isEditingHashJson" v-model="memberEditValue" class="min-h-0 flex-1" :save-disabled="savingMember || !memberValueChanged" :read-only="savingMember" :word-wrap="redisJsonWordWrap" @save="saveMemberEdit" />
|
||||
<div v-else-if="memberValueView === 'json' && selectedMemberDetail.json" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
|
||||
<JsonTree :value="selectedMemberDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
<div v-else-if="memberValueView === 'javaserialize' && selectedMemberDetail.javaSerialized" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
|
||||
|
|
@ -1601,6 +1688,16 @@ onBeforeUnmount(() => {
|
|||
{{ t("grid.save") }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="isEditingHashJson">
|
||||
<Button variant="ghost" :disabled="savingMember || !memberValueChanged" @click="discardHashJsonEdit">
|
||||
{{ t("grid.discard") }}
|
||||
</Button>
|
||||
<Button :disabled="savingMember || !memberValueChanged" @click="saveMemberEdit">
|
||||
<Loader2 v-if="savingMember" class="h-4 w-4 animate-spin" />
|
||||
<Save v-else class="h-4 w-4" />
|
||||
{{ t("grid.save") }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button v-else-if="canEditCurrentMemberFormat" variant="outline" @click="startEditMember">
|
||||
<Pencil class="h-4 w-4" />
|
||||
{{ t("redis.editMember") }}
|
||||
|
|
|
|||
|
|
@ -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("]") });
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, string>): 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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<RedisListItem>,
|
||||
|
|
@ -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<serde_json::Value, String> {
|
||||
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<String, String> {
|
||||
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::<String>();
|
||||
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::<Vec<_>>().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<Redi
|
|||
}
|
||||
}
|
||||
|
||||
fn redis_raw_to_json(value: RedisRawValue) -> 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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue