fix(grid): support text preview of binary cells

This commit is contained in:
jearhe authored and GitHub committed 2026-09-18 22:49:58 +08:00
1 parent ccc0361907
commit 1c47062cdd
16 files changed
+560 -3

No files matched your search

@@ -1,4 +1,5 @@
<script setup lang="ts">
import DataGridCellDetailTextPreview from "@/components/grid/DataGridCellDetailTextPreview.vue";
import DataGridValueTransform from "@/components/grid/DataGridValueTransform.vue";
import { computed, nextTick, ref, watch } from "vue";
import { Code2, Copy, Download, Eye, FileUp, Info, Pencil } from "@lucide/vue";
@@ -150,8 +151,12 @@ watch(
<div class="flex items-center justify-between gap-2">
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
<div class="flex items-center gap-1">
<!-- Binary cells keep `0x<hex>` as the canonical value; the transform kinds below
(timestamp/JSON/radix…) have text semantics and would misread hex. Offer the
explicit, read-only bytes decode instead. -->
<DataGridCellDetailTextPreview v-if="open && isBinaryCellColumnType(detail.type)" :identity="`${detail.rowId}:${detail.colIndex}`" :value="detail.value" :column-type="detail.type" :database-type="databaseType" :incomplete="detail.isSourceTruncated" />
<DataGridValueTransform
v-if="open && !isBinaryCellColumnType(detail.type)"
v-else-if="open"
:source="(detail.isNull ?? detail.value === null) ? null : detail.rawValue"
:identity="`${detail.rowId}:${detail.colIndex}`"
:incomplete="detail.isSourceTruncated"
@@ -1,4 +1,5 @@
<script setup lang="ts">
import DataGridCellDetailTextPreview from "@/components/grid/DataGridCellDetailTextPreview.vue";
import DataGridValueTransform from "@/components/grid/DataGridValueTransform.vue";
import { computed, toRef } from "vue";
import { Code2, Copy, Download, Eye, FileDiff, FileUp, Pencil, X } from "@lucide/vue";
@@ -142,8 +143,10 @@ defineExpose({ openSearch });
<div class="flex min-h-5 min-w-0 flex-wrap items-center justify-between gap-2">
<div class="shrink-0 text-muted-foreground">{{ t("grid.cellValue") }}</div>
<div class="min-w-0 flex flex-1 flex-wrap items-center justify-end gap-1">
<!-- 与下载菜单同一道闸门:两者都读已提交的 `0x<hex>`,编辑中不对草稿做只读文本预览。 -->
<DataGridCellDetailTextPreview v-if="!editing && isBinaryCellColumnType(detail.type)" :identity="`${detail.rowId}:${detail.colIndex}`" :value="detail.value" :column-type="detail.type" :database-type="databaseType" :incomplete="detail.isSourceTruncated" />
<DataGridValueTransform
v-if="!isBinaryCellColumnType(detail.type)"
v-else-if="!isBinaryCellColumnType(detail.type)"
:source="(detail.isNull ?? detail.value === null) && (!editing || !detailEditValue || detailEditValue === detail.rawValue) ? null : editing ? detailEditValue : detail.rawValue"
:identity="`${detail.rowId}:${detail.colIndex}:${editing}`"
:incomplete="detail.isSourceTruncated"
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { copyToClipboard } from "@/lib/common/clipboard";
import { useToast } from "@/composables/useToast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { BINARY_CELL_TEXT_ENCODINGS, binaryCellTextPreview, type BinaryCellTextEncoding } from "@/lib/dataGrid/binaryCellDownload";
import type { DatabaseType } from "@/types/database";
const props = defineProps<{
/** 单元格原始值(binary canonical form `0x<hex>`),只读使用。 */
value: unknown;
columnType?: string;
databaseType?: DatabaseType;
/** 单元格身份,切换单元格时重置字符集选择并关闭弹窗。 */
identity: string;
/** 后端返回的值本身不完整(大值预览闸门)时不解码残缺 bytes。 */
incomplete?: boolean;
}>();
const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const encoding = ref<BinaryCellTextEncoding>("utf8");
// 只读 presentation:结果不回写 detail.value / rawValue,不进入编辑与提交路径。
const preview = computed(() => binaryCellTextPreview(props.value, encoding.value, props.columnType, props.databaseType, props.incomplete));
const decodedText = computed(() => (preview.value.ok ? preview.value.text : ""));
const byteLength = computed(() => (preview.value.ok ? preview.value.byteLength : 0));
const errorKey = computed(() => (preview.value.ok ? "" : `grid.binaryTextPreviewErrors.${preview.value.error}`));
watch(
() => props.identity,
() => {
open.value = false;
encoding.value = "utf8";
},
{ flush: "sync" },
);
async function copyResult() {
const result = preview.value;
if (!result.ok) return;
try {
await copyToClipboard(result.text);
toast(t("grid.cellValueCopied"), 2000);
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 5000);
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogTrigger as-child>
<Button variant="outline" size="sm" class="h-6 px-2 text-xs" @mousedown.prevent>{{ t("grid.binaryTextPreview") }}</Button>
</DialogTrigger>
<DialogContent class="flex max-h-[85vh] flex-col overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{{ t("grid.binaryTextPreviewTitle") }}</DialogTitle>
<DialogDescription>{{ t("grid.binaryTextPreviewDescription") }}</DialogDescription>
</DialogHeader>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="text-muted-foreground">{{ t("grid.binaryTextPreviewEncoding") }}</span>
<div class="flex items-center gap-1">
<Button v-for="item in BINARY_CELL_TEXT_ENCODINGS" :key="item" :variant="encoding === item ? 'secondary' : 'outline'" size="sm" class="h-7 px-2 text-xs" @click="encoding = item">
{{ t(`grid.binaryTextPreviewEncodings.${item}`) }}
</Button>
</div>
<span v-if="!errorKey" class="text-muted-foreground">{{ t("grid.binaryTextPreviewByteCount", { count: byteLength }) }}</span>
</div>
<p v-if="errorKey" role="alert" class="text-xs text-destructive">{{ t(errorKey) }}</p>
<template v-else>
<div class="flex items-center justify-between gap-2 text-xs">
<span>{{ t("grid.binaryTextPreviewResult") }}</span>
<Button variant="outline" size="sm" class="h-6 text-xs" @click="copyResult">{{ t("grid.binaryTextPreviewCopy") }}</Button>
</div>
<textarea :value="decodedText" :aria-label="t('grid.binaryTextPreviewResult')" readonly class="dbx-data-grid-value-font h-64 min-h-24 w-full resize-y rounded border bg-muted/20 p-3 text-xs" spellcheck="false" />
</template>
<p class="text-xs text-muted-foreground">{{ t("grid.binaryTextPreviewOriginalHint") }}</p>
</DialogContent>
</Dialog>
</template>
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
renderWkt: vi.fn(),
panelCancel: vi.fn(),
panelOpenSearch: vi.fn(),
copyToClipboard: vi.fn(),
toast: vi.fn(),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
@@ -53,7 +55,15 @@ vi.mock("@/components/ui/button", async () => ({ Button: (await import("./vueHos
vi.mock("@/components/ui/input", async () => ({ Input: (await import("./vueHostHarness")).createPassthroughStub("Input", "input") }));
vi.mock("@/components/ui/dialog", async () => {
const { createPassthroughStub } = await import("./vueHostHarness");
return { Dialog: createPassthroughStub("Dialog"), DialogContent: createPassthroughStub("DialogContent"), DialogFooter: createPassthroughStub("DialogFooter"), DialogHeader: createPassthroughStub("DialogHeader"), DialogTitle: createPassthroughStub("DialogTitle") };
return {
Dialog: createPassthroughStub("Dialog"),
DialogContent: createPassthroughStub("DialogContent"),
DialogDescription: createPassthroughStub("DialogDescription"),
DialogFooter: createPassthroughStub("DialogFooter"),
DialogHeader: createPassthroughStub("DialogHeader"),
DialogTitle: createPassthroughStub("DialogTitle"),
DialogTrigger: createPassthroughStub("DialogTrigger"),
};
});
vi.mock("@/components/ui/dropdown-menu", async () => {
const { createPassthroughStub } = await import("./vueHostHarness");
@@ -82,6 +92,8 @@ vi.mock("@/composables/useCellDetailEditor", () => ({ useCellDetailEditor: () =>
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: {} } }) }));
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { cellDetailJsonFormatted: true, theme: "default", fontSize: 13, fontFamily: "monospace" }, updateEditorSettings: mocks.updateSettings }) }));
vi.mock("@/lib/dataGrid/geometryPreview", () => ({ isHexGeometry: () => false, renderWktOnCanvas: mocks.renderWkt }));
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copyToClipboard }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/composables/useDataGridCellDetail", async () => {
const { ref } = await import("vue");
return {
@@ -106,6 +118,8 @@ import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
const dataGridCellDetailEditSource = readFileSync("apps/desktop/src/composables/useDataGridCellDetailEdit.ts", "utf8");
const cellDetailPanelSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailPanel.vue", "utf8");
const cellDetailDialogSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailDialog.vue", "utf8");
const binaryTextPreviewSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailTextPreview.vue", "utf8");
const cellDetailHeaderSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailHeader.vue", "utf8");
const globalsCss = readFileSync("apps/desktop/src/styles/globals.css", "utf8");
@@ -1540,6 +1554,164 @@ describe("cell detail surfaces", () => {
expect(hostText(panel.root)).toContain("0x89504e470d0a1a0a");
});
// issue #9505:GaussDB(ZenithDriver)的 BLOB 已经以 `0x<hex>` 到达前端,缺的是详情里显式、只读的文本查看入口。
it("offers an explicit read-only binary text preview in the dialog and the panel", () => {
const blobDetail = detail({
type: "BLOB",
value: "0x5b5b68656164657273203d207b7d",
rawValue: "0x5b5b68656164657273203d207b7d",
rawValuePreview: "0x5b5b68656164657273203d207b7d",
displayValue: "BLOB [14 bytes]",
displayValuePreview: "BLOB [14 bytes]",
formattedJson: "",
});
const dialog = mountComponent(DataGridCellDetailDialog, {
open: true,
detail: blobDetail,
typeColorClass: () => "",
openImagePreview: vi.fn(),
copyText: vi.fn(),
canDownloadBinaryValue: () => true,
downloadBinaryValue: vi.fn(),
canImportBinaryValue: () => false,
importBinaryValue: vi.fn(),
databaseType: "gaussdb",
});
const panel = mountComponent(DataGridCellDetailPanel, {
detail: blobDetail,
panelIsBottom: true,
metadataCollapsed: false,
valueFillsHeight: false,
editing: false,
sideJsonView: false,
showCompactJson: false,
canCompactJson: false,
typeColorClass: () => "",
canDownloadBinaryValue: () => true,
downloadBinaryValue: vi.fn(),
canImportBinaryValue: () => false,
importBinaryValue: vi.fn(),
openImagePreview: vi.fn(),
canCopySqlCondition: () => true,
databaseType: "gaussdb",
});
for (const mounted of [dialog, panel]) {
// canonical 值仍是 0x 十六进制,解码文本只出现在只读预览里。
expect(hostText(mounted.root)).toContain("0x5b5b68656164657273203d207b7d");
expect(hostText(mounted.root)).toContain("grid.binaryTextPreview");
// binary 列不再渲染 timestamp/radix 这类对 hex 无意义的文本转换。
expect(findAll(mounted.root, (node) => node.props["data-stub"] === "DataGridValueTransform")).toHaveLength(0);
expect(findOne(mounted.root, (node) => node.type === "textarea").value).toBe("[[headers = {}");
}
});
it("re-decodes the binary text preview with GBK only after the user picks it", async () => {
const panel = mountComponent(DataGridCellDetailPanel, {
detail: detail({ type: "BLOB", value: "0xd6d0cec4", rawValue: "0xd6d0cec4", rawValuePreview: "0xd6d0cec4", displayValue: "BLOB [4 bytes]", displayValuePreview: "BLOB [4 bytes]", formattedJson: "" }),
panelIsBottom: true,
metadataCollapsed: false,
valueFillsHeight: false,
editing: false,
sideJsonView: false,
showCompactJson: false,
canCompactJson: false,
typeColorClass: () => "",
canDownloadBinaryValue: () => true,
downloadBinaryValue: vi.fn(),
canImportBinaryValue: () => false,
importBinaryValue: vi.fn(),
openImagePreview: vi.fn(),
canCopySqlCondition: () => true,
databaseType: "gaussdb",
});
// 默认 UTF-8:GBK 字节严格解码失败时给出失败提示,而不是替换字符。
expect(hostText(panel.root)).toContain("grid.binaryTextPreviewErrors.undecodable");
dispatch(
findOne(panel.root, (node) => node.type === "button" && hostText(node).trim() === "grid.binaryTextPreviewEncodings.gbk"),
"click",
);
await nextTick();
expect(hostText(panel.root)).not.toContain("grid.binaryTextPreviewErrors.undecodable");
expect(findOne(panel.root, (node) => node.type === "textarea").value).toBe("中文");
});
it("keeps the value transform entry for non-binary detail cells", () => {
const panel = mountComponent(DataGridCellDetailPanel, {
detail: detail({ type: "VARCHAR(32)", value: "hello", rawValue: "hello", rawValuePreview: "hello", displayValue: "hello", displayValuePreview: "hello", formattedJson: "" }),
panelIsBottom: true,
metadataCollapsed: false,
valueFillsHeight: false,
editing: false,
sideJsonView: false,
showCompactJson: false,
canCompactJson: false,
typeColorClass: () => "",
canDownloadBinaryValue: () => false,
downloadBinaryValue: vi.fn(),
canImportBinaryValue: () => false,
importBinaryValue: vi.fn(),
openImagePreview: vi.fn(),
canCopySqlCondition: () => true,
databaseType: "mysql",
});
expect(findAll(panel.root, (node) => node.props["data-stub"] === "DataGridValueTransform")).toHaveLength(1);
expect(hostText(panel.root)).not.toContain("grid.binaryTextPreviewTitle");
expect(findAll(panel.root, (node) => node.type === "textarea")).toHaveLength(0);
});
it("keeps the binary text preview out of the copy-back and edit paths", async () => {
const copyValue = vi.fn();
const startEdit = vi.fn();
const panel = mountComponent(DataGridCellDetailPanel, {
detail: detail({ type: "BLOB", value: "0x48656c6c6f", rawValue: "0x48656c6c6f", rawValuePreview: "0x48656c6c6f", displayValue: "BLOB [5 bytes]", displayValuePreview: "BLOB [5 bytes]", formattedJson: "" }),
panelIsBottom: true,
metadataCollapsed: false,
valueFillsHeight: false,
editing: false,
sideJsonView: false,
showCompactJson: false,
canCompactJson: false,
typeColorClass: () => "",
canDownloadBinaryValue: () => true,
downloadBinaryValue: vi.fn(),
canImportBinaryValue: () => false,
importBinaryValue: vi.fn(),
openImagePreview: vi.fn(),
canCopySqlCondition: () => true,
databaseType: "gaussdb",
onCopyValue: copyValue,
onStartEdit: startEdit,
});
dispatch(
findOne(panel.root, (node) => node.type === "button" && hostText(node).trim() === "grid.binaryTextPreviewCopy"),
"click",
);
await nextTick();
expect(mocks.copyToClipboard).toHaveBeenCalledWith("Hello");
expect(mocks.toast).toHaveBeenCalledWith("grid.cellValueCopied", 2000);
// 预览只读:不触发栅格复制/编辑,details 预览仍展示未修改的 canonical hex。
expect(copyValue).not.toHaveBeenCalled();
expect(startEdit).not.toHaveBeenCalled();
expect(hostText(panel.root)).toContain("0x48656c6c6f");
expect(findOne(panel.root, (node) => node.type === "textarea").value).toBe("Hello");
});
it("keeps the binary text preview component strictly read-only and shared by both surfaces", () => {
expect(binaryTextPreviewSource).toContain("binaryCellTextPreview(props.value");
// 不发出任何编辑/提交事件,也不给 detail.value / rawValue 赋值。
expect(binaryTextPreviewSource).not.toContain("defineEmits");
expect(binaryTextPreviewSource).not.toMatch(/(?:props|detail)\.(?:value|rawValue)\s*=/);
expect(cellDetailDialogSource).toMatch(/<DataGridCellDetailTextPreview\s+v-if="open && isBinaryCellColumnType\(detail\.type\)"/);
// Panel 与下载菜单同一道闸门:编辑中不对草稿做只读文本预览。
expect(cellDetailPanelSource).toMatch(/<DataGridCellDetailTextPreview\s+v-if="!editing && isBinaryCellColumnType\(detail\.type\)"/);
});
it("copies the presented value, emits edit, closes, and replaces the JSON result", async () => {
const copyText = vi.fn();
const edit = vi.fn();