fix(desktop): guard cell detail previews for large values
This commit is contained in:
parent
41e97baa6e
commit
b9fa36763e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -563,6 +563,7 @@ export default {
|
|||
formattedJson: "格式化 JSON",
|
||||
formattedValue: "格式化值",
|
||||
rawValue: "原始值",
|
||||
largeValuePreviewHint: "仅预览前 {count} 个字符,复制仍会使用完整值。",
|
||||
copyValue: "复制值",
|
||||
downloadBinaryValue: "下载值",
|
||||
downloadSaved: "已保存到 {path}",
|
||||
|
|
|
|||
|
|
@ -563,6 +563,7 @@ export default {
|
|||
formattedJson: "格式化 JSON",
|
||||
formattedValue: "格式化值",
|
||||
rawValue: "原始值",
|
||||
largeValuePreviewHint: "僅預覽前 {count} 個字元,複製仍會使用完整值。",
|
||||
copyValue: "複製值",
|
||||
downloadBinaryValue: "下載值",
|
||||
downloadSaved: "已儲存到 {path}",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue