feat(grid): preview text stored in MySQL BLOB cells

This commit is contained in:
zipg authored and GitHub committed 2026-08-24 20:34:04 +08:00
1 parent 566bc87014
commit f45fcdb69d
8 files changed
+217 -15

No files matched your search

+10 -6
View File
@@ -141,6 +141,8 @@ import {
canDownloadBinaryCellValue,
downloadBinaryCellPayload,
formatBinaryCellByteSize,
binaryCellUtf8Text,
isBlobCellColumnType,
isBinaryCellColumnType,
openBinaryCellFile,
parseBinaryCellBytes,
@@ -4120,8 +4122,8 @@ function cellEditContentNeedsExpandedEditor(options: { displayText: string; edit
function cellEditorTextForValue(value: CellValue | undefined, columnIndex: number): string {
return dataGridCellEditorText({
value: value ?? null,
databaseType: props.databaseType,
columnInfo: tableColumnForGridColumn(columnIndex),
databaseType: resolvedDatabaseType.value,
columnInfo: tableColumnForGridColumn(columnIndex) ?? resultColumnInfoForGridColumn(columnIndex),
});
}
@@ -4185,7 +4187,7 @@ function isIoTDBTimestampColumn(columnIndex: number): boolean {
}
function inlineCellEditorText(value: CellValue, columnIndex: number): string {
const columnInfo = tableColumnForGridColumn(columnIndex);
const columnInfo = tableColumnForGridColumn(columnIndex) ?? resultColumnInfoForGridColumn(columnIndex);
const columnType = props.result.column_types?.[columnIndex] ?? columnInfo?.data_type;
return (
formatIoTDBTimestampEditorValue(value, resolvedDatabaseType.value, columnType, resolvedConnectionConfig.value?.url_params) ??
@@ -6054,8 +6056,8 @@ watch(activeCellDetail, (detail) => {
}
const value = dataGridCellEditorText({
value: detail.value,
databaseType: props.databaseType,
columnInfo: tableColumnForGridColumn(detail.colIndex),
databaseType: resolvedDatabaseType.value,
columnInfo: tableColumnForGridColumn(detail.colIndex) ?? resultColumnInfoForGridColumn(detail.colIndex),
});
detailEditValue.value = value;
detailEditOriginalValue.value = value;
@@ -9100,7 +9102,7 @@ async function copyDetailValue() {
if (!initialDetail || !(await hydrateLargeValueCell(initialDetail.rowId, initialDetail.colIndex))) return;
const detail = activeCellDetail.value;
if (!detail) return;
const text = detail.value === null ? "" : displayCellValue(detail.value);
const text = detail.value === null ? "" : isBlobCellColumnType(detail.type) ? (binaryCellUtf8Text(detail.value, detail.type, resolvedDatabaseType.value) ?? displayCellValue(detail.value)) : displayCellValue(detail.value);
copyText(text);
}
@@ -13356,6 +13358,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
:import-binary-value="importDetailBinaryValue"
:open-image-preview="openImagePreview"
:can-copy-sql-condition="canCopyPreparedDetailSqlCondition"
:database-type="resolvedDatabaseType"
@start-edit="startDetailEdit"
@compact-json="compactDetailJson"
@toggle-formatted="toggleCellDetailJsonFormatted"
@@ -13545,6 +13548,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
:download-binary-value="downloadDetailBinaryValue"
:can-import-binary-value="canImportDetailBinaryValue"
:import-binary-value="importDetailBinaryValue"
:database-type="resolvedDatabaseType"
@edit="openDialogCellInSidePanel"
/>
@@ -9,10 +9,11 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
import { BINARY_CELL_DOWNLOAD_MODES, type BinaryCellDownloadMode } from "@/lib/dataGrid/binaryCellDownload";
import { BINARY_CELL_DOWNLOAD_MODES, binaryCellUtf8Text, isBlobCellColumnType, type BinaryCellDownloadMode } from "@/lib/dataGrid/binaryCellDownload";
import { isGeometryColumnType } from "@/lib/dataGrid/cellDetailPresentation";
import { isHexGeometry, renderWktOnCanvas } from "@/lib/dataGrid/geometryPreview";
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
import type { DatabaseType } from "@/types/database";
const { t } = useI18n();
const settingsStore = useSettingsStore();
@@ -27,6 +28,8 @@ const props = defineProps<{
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
canImportBinaryValue: (detail: DataGridCellDetail | null) => boolean;
importBinaryValue: (detail: DataGridCellDetail | null) => void | Promise<void>;
/** BLOB 文本预览与编辑写回一致,仅在 MySQL 连接开启。 */
databaseType?: DatabaseType;
}>();
const emit = defineEmits<{
@@ -41,6 +44,8 @@ let jsonPreviewEditor: UseCellDetailEditorReturn | null = null;
const jsonFormatted = computed(() => settingsStore.editorSettings.cellDetailJsonFormatted);
const jsonView = computed(() => jsonFormatted.value && !!props.detail?.formattedJson);
const binaryTextPreview = computed(() => (props.detail && isBlobCellColumnType(props.detail.type) ? binaryCellUtf8Text(props.detail.value, props.detail.type, props.databaseType) : null));
const presentedValuePreview = computed(() => (binaryTextPreview.value === null ? props.detail?.rawValuePreview : props.detail?.displayValuePreview) ?? "");
function toggleJsonFormatted() {
settingsStore.updateEditorSettings({ cellDetailJsonFormatted: !jsonFormatted.value });
@@ -53,7 +58,7 @@ function copyCurrentValue() {
props.copyText(detail.formattedJson);
return;
}
props.copyText(detail.value === null ? "" : detail.rawValue);
props.copyText(detail.value === null ? "" : (binaryTextPreview.value ?? detail.rawValue));
}
function copyColumnName() {
@@ -186,7 +191,7 @@ watch(
<img :src="detail.imagePreviewUrl" :alt="detail.column" loading="lazy" decoding="async" referrerpolicy="no-referrer" class="max-h-72 w-full object-contain" />
</a>
<div v-if="jsonView && detail.formattedJson" ref="jsonPreviewContainer" data-cell-detail-editor-root class="h-[44vh] min-h-60 overflow-hidden rounded border bg-muted/20 p-3" />
<pre v-else class="dbx-data-grid-value-font max-h-[44vh] overflow-auto rounded border bg-muted/20 p-3 text-xs whitespace-pre-wrap break-words" :class="{ 'italic text-muted-foreground': detail.value === null }">{{ detail.rawValuePreview }}</pre>
<pre v-else class="dbx-data-grid-value-font max-h-[44vh] overflow-auto rounded border bg-muted/20 p-3 text-xs whitespace-pre-wrap break-words" :class="{ 'italic text-muted-foreground': detail.value === null }">{{ presentedValuePreview }}</pre>
<div v-if="detail.isValuePreviewTruncated && !jsonView" class="text-[11px] text-muted-foreground">
{{ t("grid.largeValuePreviewHint", { count: detail.rawValuePreview.length }) }}
</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { toRef } from "vue";
import { computed, toRef } from "vue";
import { Code2, Copy, Download, Eye, FileUp, Pencil, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
@@ -8,11 +8,12 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { TabsContent } from "@/components/ui/tabs";
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
import { useDataGridCellDetail } from "@/composables/useDataGridCellDetail";
import { BINARY_CELL_DOWNLOAD_MODES, type BinaryCellDownloadMode } from "@/lib/dataGrid/binaryCellDownload";
import { BINARY_CELL_DOWNLOAD_MODES, binaryCellUtf8Text, isBlobCellColumnType, type BinaryCellDownloadMode } from "@/lib/dataGrid/binaryCellDownload";
import { isGeometryColumnType } from "@/lib/dataGrid/cellDetailPresentation";
import { isHexGeometry } from "@/lib/dataGrid/geometryPreview";
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
import type { TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor";
import type { DatabaseType } from "@/types/database";
const { t } = useI18n();
@@ -34,6 +35,8 @@ const props = defineProps<{
importBinaryValue: (detail: DataGridCellDetail | null) => void | Promise<void>;
openImagePreview: (src: string, title: string) => void;
canCopySqlCondition: () => boolean;
/** BLOB 文本预览与编辑写回一致,仅在 MySQL 连接开启。 */
databaseType?: DatabaseType;
}>();
const detailEditValue = defineModel<string>("value", { default: "" });
@@ -55,6 +58,9 @@ void geometryCanvas;
void detailsEditorContainer;
void sideJsonPreviewContainer;
const binaryTextPreview = computed(() => (isBlobCellColumnType(props.detail.type) ? binaryCellUtf8Text(props.detail.value, props.detail.type, props.databaseType) : null));
const presentedValuePreview = computed(() => (binaryTextPreview.value === null ? props.detail.rawValuePreview : props.detail.displayValuePreview));
function startJsonEditFromBlankArea(event: MouseEvent) {
const target = event.target as { closest?: (selector: string) => Element | null } | null;
const line = target?.closest?.(".cm-line");
@@ -181,7 +187,7 @@ defineExpose({ openSearch });
class="dbx-data-grid-value-font overflow-auto rounded border bg-muted/20 p-2 text-xs whitespace-pre-wrap break-words cursor-pointer hover:border-primary/50"
:class="[{ 'cursor-text': detail.isEditable }, panelIsBottom && detail.imagePreviewUrl ? 'min-h-24 max-h-32 shrink-0' : '', valueFillsHeight && !detail.imagePreviewUrl ? 'min-h-0 flex-1' : '']"
@dblclick="emit('startEdit')"
>{{ detail.rawValuePreview }}</pre
>{{ presentedValuePreview }}</pre
>
<div v-if="detail.isValuePreviewTruncated && !sideJsonView" class="text-[11px] text-muted-foreground">{{ t("grid.largeValuePreviewHint", { count: detail.rawValuePreview.length }) }}</div>
</div>
@@ -1184,6 +1184,105 @@ describe("DataGridTextFilterWorkbench", () => {
});
describe("cell detail surfaces", () => {
it("presents printable LONG BLOB bytes as text and copies the presented value", async () => {
const copyText = vi.fn();
const blobDetail = detail({
type: "LONGBLOB",
value: "0x2332303035383035",
rawValue: "0x2332303035383035",
rawValuePreview: "0x2332303035383035",
displayValue: "#2005805",
displayValuePreview: "#2005",
formattedJson: "",
});
const dialog = mountComponent(DataGridCellDetailDialog, {
open: true,
detail: blobDetail,
typeColorClass: () => "",
openImagePreview: vi.fn(),
copyText,
canDownloadBinaryValue: () => true,
downloadBinaryValue: vi.fn(),
canImportBinaryValue: () => false,
importBinaryValue: vi.fn(),
databaseType: "mysql",
});
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: "mysql",
});
expect(hostText(dialog.root)).toContain("#2005");
expect(hostText(dialog.root)).not.toContain("#2005805");
expect(hostText(panel.root)).toContain("#2005");
expect(hostText(panel.root)).not.toContain("#2005805");
dispatch(
findOne(dialog.root, (node) => node.props.title === "grid.copyValue"),
"click",
);
expect(copyText).toHaveBeenCalledWith("#2005805");
});
it("keeps non-MySQL BLOB detail previews in hex", () => {
const panel = mountComponent(DataGridCellDetailPanel, {
detail: detail({ type: "BLOB", value: "0x2332303035383035", rawValue: "0x2332303035383035", rawValuePreview: "0x2332303035383035", displayValue: "BLOB [8 bytes]", displayValuePreview: "BLOB [8 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: "sqlite",
});
expect(hostText(panel.root)).toContain("0x2332303035383035");
});
it("keeps non-text LONG BLOB values in hex", () => {
const panel = mountComponent(DataGridCellDetailPanel, {
detail: detail({ type: "LONGBLOB", value: "0x89504e470d0a1a0a", rawValue: "0x89504e470d0a1a0a", rawValuePreview: "0x89504e470d0a1a0a", 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: "mysql",
});
expect(hostText(panel.root)).toContain("0x89504e470d0a1a0a");
});
it("copies the presented value, emits edit, closes, and replaces the JSON result", async () => {
const copyText = vi.fn();
const edit = vi.fn();
@@ -114,6 +114,7 @@ const HEX_ESCAPE_RE = /^(?:\\x[0-9a-fA-F]{2}|\s)+$/;
const BINARY_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob|bytea|bytes|binary|varbinary|image|raw|long\s+raw)(?:\b|\()/i;
const FIXED_BINARY_TYPE_RE = /^binary(?:\b|\()/i;
const BINARY_STRING_TYPE_RE = /^(?:binary|varbinary)(?:\b|\()/i;
const BLOB_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob)(?:\b|\()/i;
const MYSQL_FILE_IMPORT_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob|binary|varbinary)(?:\b|\()/i;
function copyBytesForBlob(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
@@ -182,6 +183,11 @@ export function isBinaryCellColumnType(columnType?: string): boolean {
return !!type && BINARY_TYPE_RE.test(type);
}
export function isBlobCellColumnType(columnType?: string): boolean {
const type = (columnType ?? "").trim();
return !!type && BLOB_TYPE_RE.test(type);
}
export function canImportBinaryCellFile(databaseType?: DatabaseType, columnType?: string): boolean {
const type = (columnType ?? "").trim();
if (databaseType === "postgres") return /^bytea(?:\b|\()/i.test(type);
@@ -200,14 +206,34 @@ export function binaryCellDisplayText(value: unknown, columnType?: string, origi
}
const bytes = parseBinaryCellBytes(value, columnType, databaseType);
if (!bytes || !isBinaryCellColumnType(columnType)) return null;
if (BINARY_STRING_TYPE_RE.test((columnType ?? "").trim())) {
const previewBytes = FIXED_BINARY_TYPE_RE.test((columnType ?? "").trim()) ? trimTrailingNullBytes(bytes) : bytes;
const text = printableUtf8Text(previewBytes);
if (isBinaryCellTextPreviewColumn(columnType, databaseType)) {
const text = binaryCellUtf8TextBytes(bytes, columnType);
if (text !== null) return text;
}
return `${binaryCellDisplayLabel(columnType)} [${formatBinaryCellByteSize(bytes.length)}]`;
}
export function binaryCellUtf8Text(value: unknown, columnType?: string, databaseType?: DatabaseType): string | null {
if (!isBinaryCellColumnType(columnType) || !isBinaryCellTextPreviewColumn(columnType, databaseType)) return null;
const bytes = parseBinaryCellBytes(value, columnType, databaseType);
if (!bytes) return null;
return binaryCellUtf8TextBytes(bytes, columnType);
}
// MySQL BLOB 的文本预览必须与编辑写回路径(coerceMysqlBlobTextValue,仅 mysql)走同一闸门:
// SQLite/DuckDB/H2/Firebird 等库同样暴露 `blob` 列,若一律按文本预览会出现
// “显示是文本、编辑器却是十六进制”的不一致,故 blob 文本预览仅在 mysql 连接开启。
// binary/varbinary 的文本预览早于该特性存在(如 TDengine BINARY 文本),保持全库通用。
function isBinaryCellTextPreviewColumn(columnType: string | undefined, databaseType: DatabaseType | undefined): boolean {
if (BINARY_STRING_TYPE_RE.test((columnType ?? "").trim())) return true;
return isBlobCellColumnType(columnType) && databaseType === "mysql";
}
function binaryCellUtf8TextBytes(bytes: Uint8Array, columnType?: string): string | null {
const previewBytes = FIXED_BINARY_TYPE_RE.test((columnType ?? "").trim()) ? trimTrailingNullBytes(bytes) : bytes;
return printableUtf8Text(previewBytes);
}
function trimTrailingNullBytes(bytes: Uint8Array): Uint8Array {
let end = bytes.length;
while (end > 0 && bytes[end - 1] === 0) end--;
@@ -1,5 +1,6 @@
import type { GridCellValue } from "@/lib/dataGrid/dataGridSql";
import type { DatabaseType, ColumnInfo } from "@/types/database";
import { binaryCellBytesToHexValue, binaryCellUtf8Text, isBlobCellColumnType } from "@/lib/dataGrid/binaryCellDownload";
import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType";
import { isBooleanColumnType } from "@/lib/dataGrid/dataGridBooleanColumn";
@@ -17,6 +18,8 @@ export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions)
const { value, oldValue } = options;
if (value === "" && options.emptyStringAsNull) return null;
if (value === "" && oldValue === null && !options.preserveEmptyString) return null;
const blobValue = coerceMysqlBlobTextValue(options);
if (blobValue !== undefined) return blobValue;
const postgresArrayValue = coercePostgresArrayValue(options);
if (postgresArrayValue !== undefined) return postgresArrayValue;
// Excel-pasted values often carry thousands separators (10,000.00) that make
@@ -68,12 +71,24 @@ function parseBooleanInput(value: string, allowNumericAliases: boolean): boolean
export function dataGridCellEditorText(options: { value: GridCellValue | undefined; databaseType: DatabaseType | undefined; columnInfo: Pick<ColumnInfo, "data_type"> | undefined }): string {
const value = options.value ?? null;
if (value === null) return "";
if (options.databaseType === "mysql" && isBlobCellColumnType(options.columnInfo?.data_type)) {
const text = binaryCellUtf8Text(value, options.columnInfo?.data_type, options.databaseType);
if (text !== null) return text;
}
if (Array.isArray(value) && options.databaseType === "postgres" && isPostgresArrayColumn(options.columnInfo, value)) {
return formatPostgresArrayText(value);
}
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
function coerceMysqlBlobTextValue(options: CoerceDataGridCellValueOptions): GridCellValue | undefined {
if (options.databaseType !== "mysql" || !isBlobCellColumnType(options.columnInfo?.data_type)) return undefined;
const originalText = binaryCellUtf8Text(options.oldValue, options.columnInfo?.data_type, options.databaseType);
if (originalText === null) return undefined;
if (options.value === originalText) return options.oldValue;
return binaryCellBytesToHexValue(new TextEncoder().encode(options.value));
}
export function dataGridCellDisplayText(options: { value: GridCellValue; databaseType: DatabaseType | undefined; columnInfo: Pick<ColumnInfo, "data_type"> | undefined }): string | undefined {
if (Array.isArray(options.value) && options.databaseType === "postgres" && isPostgresArrayColumn(options.columnInfo, options.value)) {
return formatPostgresArrayText(options.value);