feat(zookeeper): add UTF-8 value preview

Closes #6897
This commit is contained in:
t8y2 committed 2026-08-22 07:21:26 +00:00
1 parent b2cf487734
commit fe269a28db
14 files changed
+209 -6

No files matched your search

@@ -24,7 +24,7 @@ import { decideKvMetadataRefresh, hasPositiveKvLease, knownKvLeaseSummaries, mer
import { classifyKvMutationError, type KvMutationErrorKind } from "@/lib/kv/kvMutationError";
import { refreshedKvSelectionSummary } from "@/lib/kv/kvRefreshSelection";
import { parseKvLeaseId, parseOptionalTtl } from "@/lib/kv/kvTtl";
import { formatZooKeeperMetadataRows, formatZooKeeperSummaryBadges, prettyPrintJsonText } from "@/lib/kv/kvValueDisplay";
import { decodeBase64Utf8Preview, formatZooKeeperMetadataRows, formatZooKeeperSummaryBadges, prettyPrintJsonText } from "@/lib/kv/kvValueDisplay";
import { formatTtl } from "@/lib/common/ttlFormat";
import {
createLazyKvKeyTreeState,
@@ -118,6 +118,8 @@ interface KvKeyBrowserLabels {
copy?: string;
copied?: string;
copyFailed?: string;
utf8PreviewLossy?: string;
utf8PreviewUnavailable?: string;
valueTooLarge?: string;
deletePrefix?: string;
selectAll?: string;
@@ -195,6 +197,7 @@ const props = withDefaults(
safeWrite?: boolean;
maxValueBytes?: number;
allowBinaryEdit?: boolean;
enableBase64Utf8Preview?: boolean;
readOnly?: boolean;
exportFormat?: string;
exportFileExtension?: string;
@@ -221,6 +224,7 @@ const props = withDefaults(
safeWrite: false,
maxValueBytes: 0,
allowBinaryEdit: false,
enableBase64Utf8Preview: false,
readOnly: false,
exportFormat: "dbx-kv-bundle",
exportFileExtension: ".dbx-kv.json",
@@ -290,6 +294,7 @@ const showHistoryDiff = ref(false);
const restoring = ref(false);
const selectedCreateMode = ref<KvCreateMode>("persistent");
const selectedPrettyValue = ref<string | null>(null);
const selectedBase64ViewMode = ref<"utf8" | "base64">("utf8");
const selectedValueCopied = ref(false);
const lazyTreeState = reactive(createLazyKvKeyTreeState(lazyKvRootPath(props.lazyPathStyle), props.lazyPathStyle));
const multiSelectedKeys = ref<Map<string, KvMultiSelection>>(new Map());
@@ -385,8 +390,28 @@ const selectedTextValue = computed(() => {
if (!value) return "";
return value.encoding === "utf8" ? value.data : value.data;
});
const displayedSelectedTextValue = computed(() => selectedPrettyValue.value ?? selectedTextValue.value);
const selectedValueIsBase64 = computed(() => selectedValue.value?.value?.encoding === "base64");
const selectedBase64Utf8Preview = computed(() => {
if (!props.enableBase64Utf8Preview || !selectedValueIsBase64.value) return null;
return decodeBase64Utf8Preview(selectedTextValue.value);
});
const showSelectedBase64Utf8Controls = computed(() => props.enableBase64Utf8Preview && selectedValueIsBase64.value);
const selectedBase64DisplayMode = computed<"utf8" | "base64">(() => (selectedBase64ViewMode.value === "utf8" && selectedBase64Utf8Preview.value?.ok ? "utf8" : "base64"));
const selectedValueUsesUtf8Preview = computed(() => showSelectedBase64Utf8Controls.value && selectedBase64DisplayMode.value === "utf8");
const selectedBase64Utf8PreviewIsLossy = computed(() => {
const preview = selectedBase64Utf8Preview.value;
return preview?.ok === true && preview.lossy;
});
const selectedBase64Utf8PreviewUnavailable = computed(() => selectedBase64Utf8Preview.value?.ok === false);
const displayedSelectedTextValue = computed(() => {
if (selectedPrettyValue.value != null) return selectedPrettyValue.value;
const preview = selectedBase64Utf8Preview.value;
return selectedValueUsesUtf8Preview.value && preview?.ok ? preview.value : selectedTextValue.value;
});
const selectedValueClipboardText = computed(() => {
const preview = selectedBase64Utf8Preview.value;
return selectedValueUsesUtf8Preview.value && preview?.ok ? preview.value : selectedTextValue.value;
});
const selectedKeyBytes = computed(() => selectedValue.value?.keyBytes ?? selectedRouteKeyBytes.value ?? null);
const selectedKeyLocked = computed(() => Boolean(String(selectedMetadata.value?.session ?? "").trim()));
const isWatchingSelectedKey = computed(() => Boolean(selectedKey.value && props.watchActiveKey === selectedKey.value));
@@ -856,6 +881,7 @@ async function loadSelectedKey(input: string | KvKeyRoute) {
selectedRouteKeyBytes.value = route.keyBytes ?? null;
selectedValue.value = null;
selectedPrettyValue.value = null;
selectedBase64ViewMode.value = "utf8";
detailLoading.value = true;
detailError.value = "";
try {
@@ -891,6 +917,7 @@ function clearSelectedKey() {
selectedRouteKeyBytes.value = null;
selectedValue.value = null;
selectedPrettyValue.value = null;
selectedBase64ViewMode.value = "utf8";
detailLoading.value = false;
}
@@ -1364,7 +1391,7 @@ async function copyText(value: string | number | null | undefined) {
async function copySelectedValue() {
if (!selectedValue.value?.found) return;
try {
await copyToClipboard(selectedTextValue.value);
await copyToClipboard(selectedValueClipboardText.value);
selectedValueCopied.value = true;
toast(props.labels.copied || "Copied", 1500);
if (selectedValueCopyTimer) clearTimeout(selectedValueCopyTimer);
@@ -2025,7 +2052,23 @@ defineExpose({
</div>
</div>
<div class="min-h-0">
<div v-if="metadataStyle === 'zookeeper'" class="mb-2 text-xs font-medium text-muted-foreground">{{ labels.value || "Value" }}</div>
<div v-if="metadataStyle === 'zookeeper' || showSelectedBase64Utf8Controls" class="mb-2 flex min-h-8 items-center justify-between gap-3">
<div v-if="metadataStyle === 'zookeeper'" class="text-xs font-medium text-muted-foreground">{{ labels.value || "Value" }}</div>
<div v-if="showSelectedBase64Utf8Controls" class="ml-auto inline-flex rounded-md border bg-muted/20 p-0.5" role="group" aria-label="Value encoding">
<Button
data-testid="kv-base64-utf8-view"
size="sm"
:variant="selectedBase64DisplayMode === 'utf8' ? 'secondary' : 'ghost'"
class="h-7 rounded-sm px-2.5 text-xs"
:aria-pressed="selectedBase64DisplayMode === 'utf8'"
:disabled="selectedBase64Utf8PreviewUnavailable"
@click="selectedBase64ViewMode = 'utf8'"
>
UTF-8
</Button>
<Button data-testid="kv-base64-raw-view" size="sm" :variant="selectedBase64DisplayMode === 'base64' ? 'secondary' : 'ghost'" class="h-7 rounded-sm px-2.5 text-xs" :aria-pressed="selectedBase64DisplayMode === 'base64'" @click="selectedBase64ViewMode = 'base64'"> Base64 </Button>
</div>
</div>
<div class="relative">
<Button
size="icon"
@@ -2044,6 +2087,12 @@ defineExpose({
:class="settingsStore.editorSettings.wordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'"
><template v-for="(segment, index) in selectedValueHighlightSegments" :key="index"><mark v-if="segment.matched" class="rounded-sm bg-amber-300/80 px-0.5 text-foreground dark:bg-amber-500/40">{{ segment.text }}</mark><span v-else>{{ segment.text }}</span></template></pre>
</div>
<div v-if="selectedValueUsesUtf8Preview && selectedBase64Utf8PreviewIsLossy" class="mt-2 text-xs text-amber-700 dark:text-amber-300">
{{ labels.utf8PreviewLossy || "Invalid UTF-8 bytes are shown as replacement characters (�). The original Base64 value is unchanged." }}
</div>
<div v-else-if="showSelectedBase64Utf8Controls && selectedBase64Utf8PreviewUnavailable" class="mt-2 text-xs text-destructive">
{{ labels.utf8PreviewUnavailable || "The Base64 value is invalid, so the original value is shown." }}
</div>
<div v-if="selectedValueCanPrettyJson" class="mt-2 flex justify-end">
<Button size="sm" variant="outline" class="h-8" @click="prettifySelectedJson">
{{ labels.prettyJson || "Pretty" }}
@@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const browserSource = readFileSync(new URL("../KvKeyBrowser.vue", import.meta.url), "utf8");
describe("KvKeyBrowser Base64 UTF-8 preview", () => {
it("keeps the preview opt-in for backend-specific wrappers", () => {
expect(browserSource).toContain("enableBase64Utf8Preview?: boolean");
expect(browserSource).toContain("enableBase64Utf8Preview: false");
});
it("copies the selected derived view while preserving the raw value path", () => {
expect(browserSource).toContain("selectedValueClipboardText");
expect(browserSource).toContain("copyToClipboard(selectedValueClipboardText.value)");
expect(browserSource.match(/selectedBase64ViewMode\.value = "utf8"/g)).toHaveLength(2);
expect(browserSource).toContain("editValue.value = selectedTextValue.value");
expect(browserSource).toContain("value: selectedValue.value.value");
});
it("offers explicit UTF-8 and Base64 view controls with error feedback", () => {
expect(browserSource).toContain('data-testid="kv-base64-utf8-view"');
expect(browserSource).toContain('data-testid="kv-base64-raw-view"');
expect(browserSource).toContain("labels.utf8PreviewLossy");
expect(browserSource).toContain("labels.utf8PreviewUnavailable");
});
});
@@ -43,6 +43,8 @@ const labels = computed(() => ({
saved: t("zookeeper.saved"),
deleted: t("zookeeper.deleted"),
base64Readonly: t("zookeeper.base64Readonly"),
utf8PreviewLossy: t("zookeeper.utf8PreviewLossy"),
utf8PreviewUnavailable: t("zookeeper.utf8PreviewUnavailable"),
createMode: t("zookeeper.createMode"),
add: t("zookeeper.add"),
value: t("zookeeper.value"),
@@ -67,5 +69,5 @@ defineExpose({ focusSearch, refresh });
</script>
<template>
<KvKeyBrowser ref="browserRef" :connection-id="props.connectionId" :api="zookeeperApi" :labels="labels" supports-create-modes enable-node-actions metadata-style="zookeeper" lazy-hierarchy :create-mode-options="createModeOptions" />
<KvKeyBrowser ref="browserRef" :connection-id="props.connectionId" :api="zookeeperApi" :labels="labels" supports-create-modes enable-node-actions enable-base64-utf8-preview metadata-style="zookeeper" lazy-hierarchy :create-mode-options="createModeOptions" />
</template>
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, type App } from "vue";
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/backend/api", () => ({
zookeeperListPrefix: vi.fn(),
zookeeperGet: vi.fn(),
zookeeperPut: vi.fn(),
zookeeperDelete: vi.fn(),
}));
vi.mock("@/components/kv/KvKeyBrowser.vue", () => ({
default: defineComponent({
name: "KvKeyBrowserStub",
props: {
enableBase64Utf8Preview: { type: Boolean, default: false },
labels: { type: Object, required: true },
},
setup(props) {
return () =>
h("div", {
id: "kv-browser-stub",
"data-base64-utf8-preview": String(props.enableBase64Utf8Preview),
"data-lossy-label": (props.labels as Record<string, string>).utf8PreviewLossy,
});
},
}),
}));
import ZooKeeperKeyBrowser from "@/components/zookeeper/ZooKeeperKeyBrowser.vue";
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
afterEach(() => {
app?.unmount();
root?.remove();
app = null;
root = null;
});
describe("ZooKeeperKeyBrowser", () => {
it("enables the Base64 UTF-8 preview with ZooKeeper labels", () => {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(ZooKeeperKeyBrowser, { connectionId: "zookeeper-1" });
app.mount(root);
const browser = root.querySelector("#kv-browser-stub");
expect(browser?.getAttribute("data-base64-utf8-preview")).toBe("true");
expect(browser?.getAttribute("data-lossy-label")).toBe("zookeeper.utf8PreviewLossy");
});
});