fix(desktop): guard cell detail previews for large values

This commit is contained in:
t8y2 2026-06-01 19:49:53 +08:00
parent 41e97baa6e
commit b9fa36763e
9 changed files with 80 additions and 11 deletions

View File

@ -6296,14 +6296,24 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
class="max-h-56 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre cursor-pointer hover:border-primary/50"
:class="{ 'cursor-text': activeCellDetail.isEditable }"
@dblclick="startDetailEdit"
>{{ activeCellDetail.displayValue }}</pre
>{{ activeCellDetail.displayValuePreview }}</pre
>
<div v-if="activeCellDetail.isValuePreviewTruncated" class="text-[11px] text-muted-foreground">
{{
t("grid.largeValuePreviewHint", {
count: activeCellDetail.displayValuePreview.length,
})
}}
</div>
</div>
<div v-if="activeCellDetail.displayValue !== activeCellDetail.rawValue" class="space-y-1">
<div
v-if="activeCellDetail.displayValuePreview !== activeCellDetail.rawValuePreview"
class="space-y-1"
>
<div class="text-muted-foreground">{{ t("grid.rawValue") }}</div>
<pre
class="max-h-40 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
>{{ activeCellDetail.rawValue }}</pre
>{{ activeCellDetail.rawValuePreview }}</pre
>
</div>
<div v-if="activeCellDetail.formattedJson" class="mt-2 space-y-1">
@ -6634,14 +6644,17 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<pre
class="max-h-[44vh] overflow-auto rounded border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap break-words"
:class="{ 'italic text-muted-foreground': dialogCellDetail.value === null }"
>{{ dialogCellDetail.rawValue }}</pre
>{{ dialogCellDetail.rawValuePreview }}</pre
>
<div v-if="dialogCellDetail.isValuePreviewTruncated" class="text-[11px] text-muted-foreground">
{{ t("grid.largeValuePreviewHint", { count: dialogCellDetail.rawValuePreview.length }) }}
</div>
</div>
<div v-if="dialogCellDetail.displayValue !== dialogCellDetail.rawValue" class="space-y-1">
<div v-if="dialogCellDetail.displayValuePreview !== dialogCellDetail.rawValuePreview" class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.formattedValue") }}</div>
<pre class="max-h-40 overflow-auto rounded border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap">{{
dialogCellDetail.displayValue
dialogCellDetail.displayValuePreview
}}</pre>
</div>
@ -6741,8 +6754,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<pre
class="max-h-44 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
:class="{ 'italic text-muted-foreground': field.value === null }"
>{{ field.rawValue }}</pre
>{{ field.rawValuePreview }}</pre
>
<div v-if="field.isValuePreviewTruncated" class="mt-1 text-[11px] text-muted-foreground">
{{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }}
</div>
<div v-if="field.formattedJson" class="mt-2 space-y-1">
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
<pre
@ -6850,8 +6866,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<pre
class="max-h-36 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
:class="{ 'italic text-muted-foreground': field.value === null }"
>{{ field.rawValue }}</pre
>{{ field.rawValuePreview }}</pre
>
<div v-if="field.isValuePreviewTruncated" class="mt-1 text-[11px] text-muted-foreground">
{{ t("grid.largeValuePreviewHint", { count: field.rawValuePreview.length }) }}
</div>
<div v-if="field.formattedJson" class="mt-2 space-y-1">
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
<pre

View File

@ -20,7 +20,7 @@ import {
} from "@/lib/editorThemes";
import { shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry";
import { useSettingsStore } from "@/stores/settingsStore";
import { isJsonColumnType } from "@/lib/cellDetailPresentation";
import { CELL_DETAIL_JSON_FORMAT_MAX_LENGTH, isJsonColumnType } from "@/lib/cellDetailPresentation";
import {
clampEditorFontSize,
createEditorZoomCommitScheduler,
@ -63,6 +63,7 @@ function looksLikeJsonString(text: string): boolean {
}
function shouldUseJsonMode(columnType?: string, value?: string): boolean {
if (value && value.length > CELL_DETAIL_JSON_FORMAT_MAX_LENGTH) return false;
if (isJsonColumnType(columnType)) return true;
if (value && looksLikeJsonString(value)) return true;
return false;

View File

@ -568,6 +568,7 @@ export default {
formattedJson: "Formatted JSON",
formattedValue: "Formatted Value",
rawValue: "Raw Value",
largeValuePreviewHint: "Previewing first {count} characters. Copy still uses the full value.",
copyValue: "Copy Value",
downloadBinaryValue: "Download Value",
downloadSaved: "Saved to {path}",

View File

@ -539,6 +539,7 @@ export default {
formattedJson: "JSON formateado",
formattedValue: "Valor formateado",
rawValue: "Valor sin procesar",
largeValuePreviewHint: "Mostrando los primeros {count} caracteres. Copiar sigue usando el valor completo.",
copyValue: "Copiar valor",
downloadBinaryValue: "Descargar valor",
downloadSaved: "Guardado en {path}",

View File

@ -563,6 +563,7 @@ export default {
formattedJson: "格式化 JSON",
formattedValue: "格式化值",
rawValue: "原始值",
largeValuePreviewHint: "仅预览前 {count} 个字符,复制仍会使用完整值。",
copyValue: "复制值",
downloadBinaryValue: "下载值",
downloadSaved: "已保存到 {path}",

View File

@ -563,6 +563,7 @@ export default {
formattedJson: "格式化 JSON",
formattedValue: "格式化值",
rawValue: "原始值",
largeValuePreviewHint: "僅預覽前 {count} 個字元,複製仍會使用完整值。",
copyValue: "複製值",
downloadBinaryValue: "下載值",
downloadSaved: "已儲存到 {path}",

View File

@ -1,6 +1,8 @@
export type CellDetailTab = "details" | "valueEditor";
export type ValueEditorAction = "formatJson" | "setNull" | "restoreOriginal";
export const CELL_DETAIL_JSON_FORMAT_MAX_LENGTH = 100_000;
export interface CellDetailPresentationOptions {
isEditable: boolean;
}
@ -66,6 +68,7 @@ export function isJsonColumnType(columnType: string | undefined): boolean {
export function canFormatCellDetailJson(value: unknown, columnType?: string): boolean {
if (value === null || value === undefined) return false;
const text = cellDetailRawEditorText(value);
if (text.length > CELL_DETAIL_JSON_FORMAT_MAX_LENGTH) return false;
if (isJsonColumnType(columnType)) return !!formatJsonText(text);
return typeof value === "string" && looksLikeJsonContainer(text) && !!formatJsonText(text);
}
@ -73,6 +76,7 @@ export function canFormatCellDetailJson(value: unknown, columnType?: string): bo
export function formatJsonText(text: string): string | undefined {
const trimmed = text.trim();
if (!trimmed) return undefined;
if (trimmed.length > CELL_DETAIL_JSON_FORMAT_MAX_LENGTH) return undefined;
try {
return JSON.stringify(JSON.parse(trimmed), null, 2);
} catch {

View File

@ -2,6 +2,8 @@ import { cellImagePreviewUrl } from "@/lib/cellImageUrl";
import { displayCellValue, type CellValue } from "@/lib/cellValue";
import { formatJsonText } from "@/lib/cellDetailPresentation";
export const CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH = 12_000;
export interface DataGridCellDetail {
rowNumber: number;
rowId: number;
@ -11,7 +13,10 @@ export interface DataGridCellDetail {
comment: string;
value: CellValue;
rawValue: string;
rawValuePreview: string;
displayValue: string;
displayValuePreview: string;
isValuePreviewTruncated: boolean;
imagePreviewUrl: string | null;
length: number;
formattedJson: string;
@ -78,7 +83,10 @@ export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions)
const value = options.row[options.columnIndex] ?? null;
const rawValue = displayCellValue(value);
const displayValue = options.displayValue(value, options.columnIndex);
const formattedJson = typeof value === "string" && looksLikeJsonContainer(value) ? (formatJsonText(value) ?? "") : "";
const rawValuePreview = previewText(rawValue);
const displayValuePreview = previewText(displayValue);
return {
rowNumber: options.rowIndex + 1,
@ -89,8 +97,12 @@ export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions)
comment: options.commentByColumn?.get(column) ?? "",
value,
rawValue,
displayValue: options.displayValue(value, options.columnIndex),
imagePreviewUrl: cellImagePreviewUrl(value),
rawValuePreview,
displayValue,
displayValuePreview,
isValuePreviewTruncated:
rawValuePreview.length < rawValue.length || displayValuePreview.length < displayValue.length,
imagePreviewUrl: rawValue.length <= CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH ? cellImagePreviewUrl(value) : null,
length: value === null ? 0 : String(value).length,
formattedJson,
isEditable: options.isEditable,
@ -181,3 +193,8 @@ function looksLikeJsonContainer(text: string): boolean {
const trimmed = text.trim();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
function previewText(text: string): string {
if (text.length <= CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH) return text;
return text.slice(0, CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH);
}

View File

@ -50,7 +50,10 @@ test("buildDataGridCellDetail preserves full value metadata", () => {
comment: "raw event payload",
value: '{"ok":true,"items":[1,2]}',
rawValue: '{"ok":true,"items":[1,2]}',
rawValuePreview: '{"ok":true,"items":[1,2]}',
displayValue: 'formatted:{"ok":tr',
displayValuePreview: 'formatted:{"ok":tr',
isValuePreviewTruncated: false,
imagePreviewUrl: null,
length: 25,
formattedJson: '{\n "ok": true,\n "items": [\n 1,\n 2\n ]\n}',
@ -72,6 +75,27 @@ test("buildDataGridCellDetail reports image preview URLs", () => {
assert.equal(detail?.imagePreviewUrl, "https://example.com/avatar.png");
});
test("buildDataGridCellDetail limits rendered previews for huge text values", () => {
const hugeJson = `{"body":"${"x".repeat(120_000)}"}`;
const detail = buildDataGridCellDetail({
rowIndex: 0,
rowId: 1,
row: [hugeJson],
columns: ["payload"],
columnIndex: 0,
typeByColumn: new Map([["payload", "nvarchar(max)"]]),
displayValue: (value) => String(value),
isEditable: false,
});
assert.ok(detail);
assert.equal(detail.rawValue, hugeJson);
assert.equal(detail.rawValuePreview.length, 12_000);
assert.equal(detail.displayValuePreview.length, 12_000);
assert.equal(detail.isValuePreviewTruncated, true);
assert.equal(detail.formattedJson, "");
});
test("buildDataGridRowDetail maps requested column indexes in order", () => {
const row: CellValue[] = ["Ada", null, true];
const detail = buildDataGridRowDetail({