feat(mongo): add document JSON preview

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-11 01:00:47 +08:00
1 parent ee4658bac2
commit 264909aaa9
23 files changed
+387 -18

No files matched your search

+158 -1
View File
@@ -119,7 +119,18 @@ import { BINARY_CELL_DOWNLOAD_MODES, binaryCellDisplayText, binaryCellDownloadFi
import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer";
import { canFormatCellDetailJson, cellDetailEditorText, compactJsonText, defaultCellDetailTab, formatJsonText, isGeometryColumnType, linkedCellDetailTarget, looksLikeJsonContainerText, valueEditorActions, visibleCellDetailTabs, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation";
import { renderWktOnCanvas, isHexGeometry } from "@/lib/dataGrid/geometryPreview";
import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, filterDataGridDetailFields, type DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
import {
buildDataGridCellDetail,
buildDataGridColumnDetail,
buildDataGridRowDetail,
CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH,
dataGridColumnDetailJson,
dataGridColumnDetailTsv,
dataGridRowDetailJson,
dataGridRowDetailTsv,
filterDataGridDetailFields,
type DataGridCellDetail,
} from "@/lib/dataGrid/dataGridDetail";
import { applyColumnFormatter, buildColumnFormatterKey, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/dataGrid/columnFormatter";
import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor";
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
@@ -485,6 +496,7 @@ const detailCell = ref<{ rowIndex: number; col: number } | null>(null);
const hoveredDetailCell = ref<{ rowIndex: number; col: number } | null>(null);
const quickDownloadMenuCell = ref<{ rowIndex: number; col: number } | null>(null);
const showCellDetail = ref(false);
const showMongoJsonPreview = ref(false);
const activeCellDetailTab = ref<CellDetailTab>(defaultCellDetailTab());
const cellDetailDialogOpen = ref(false);
const cellDetailDialogTarget = ref<{ rowIndex: number; col: number } | null>(null);
@@ -4787,6 +4799,42 @@ const activeCellDetail = computed(() => {
return cell ? cellDetailFor(cell.rowIndex, cell.col) : null;
});
const canShowMongoJsonPreview = computed(() => props.databaseType === "mongodb" && !!props.result.mongo_documents && props.result.mongo_documents.length === props.result.rows.length);
const mongoJsonPreviewOpen = computed(() => showMongoJsonPreview.value && canShowMongoJsonPreview.value);
const activeMongoJsonDocument = computed(() => {
if (!mongoJsonPreviewOpen.value) return undefined;
const selectedCell = currentSelectedCellPosition();
if (!selectedCell) return undefined;
const item = displayItemAt(selectedCell.rowIndex);
return item?.sourceIndex === undefined ? undefined : props.result.mongo_documents?.[item.sourceIndex];
});
const mongoJsonPreviewFullText = computed(() => {
const document = activeMongoJsonDocument.value;
if (document === undefined) return "";
try {
return JSON.stringify(document, null, 2) ?? "";
} catch {
return "";
}
});
const mongoJsonPreviewText = computed(() => mongoJsonPreviewFullText.value.slice(0, CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH));
const mongoJsonPreviewTruncated = computed(() => mongoJsonPreviewText.value.length < mongoJsonPreviewFullText.value.length);
const mongoJsonPreviewUsesCodeEditor = computed(() => !!mongoJsonPreviewText.value && !mongoJsonPreviewTruncated.value);
watch(canShowMongoJsonPreview, (available) => {
if (!available) showMongoJsonPreview.value = false;
});
// Result-set switches remount the grid, but re-executing the same result set
// keeps this component alive. Clear the ephemeral preview before fresh query
// data arrives so it cannot retain a stale row selection or drawer state.
watch(
() => props.loading,
(loading) => {
if (loading) showMongoJsonPreview.value = false;
},
);
const dialogCellDetail = computed(() => {
const target = cellDetailDialogTarget.value;
return target ? cellDetailFor(target.rowIndex, target.col) : null;
@@ -5041,10 +5089,12 @@ const detailsEditorContainer = ref<HTMLElement>();
const valueEditorContainer = ref<HTMLElement>();
const sideJsonPreviewContainer = ref<HTMLElement>();
const dialogJsonPreviewContainer = ref<HTMLElement>();
const mongoJsonPreviewContainer = ref<HTMLElement>();
let detailsDetailEditor: UseCellDetailEditorReturn | null = null;
let valueDetailEditor: UseCellDetailEditorReturn | null = null;
let sideJsonPreviewEditor: UseCellDetailEditorReturn | null = null;
let dialogJsonPreviewEditor: UseCellDetailEditorReturn | null = null;
let mongoJsonPreviewEditor: UseCellDetailEditorReturn | null = null;
const editorThemeAccessor = () => settingsStore.editorSettings.theme;
const editorAppAppearance = () => (isDark.value ? "dark" : "light") as import("@/lib/app/appTheme").AppThemeAppearance;
@@ -5143,10 +5193,32 @@ watch(dialogJsonPreviewContainer, async (el) => {
}
});
watch(mongoJsonPreviewContainer, async (el) => {
if (el && !mongoJsonPreviewEditor) {
mongoJsonPreviewEditor = useCellDetailEditor({
language: "json",
readOnly: true,
editorTheme: editorThemeAccessor,
appAppearance: editorAppAppearance,
appPalette: editorAppPalette,
fontSize: editorFontSize,
fontFamily: editorFontFamily,
});
await mongoJsonPreviewEditor.create(el, mongoJsonPreviewText.value, "json");
} else if (!el && mongoJsonPreviewEditor) {
mongoJsonPreviewEditor.destroy();
mongoJsonPreviewEditor = null;
}
});
watch(sideJsonPreviewText, (value) => {
sideJsonPreviewEditor?.setValue(value, "json");
});
watch(mongoJsonPreviewText, (value) => {
mongoJsonPreviewEditor?.setValue(value, "json");
});
watch(
() => dialogCellDetail.value?.formattedJson ?? "",
(value) => {
@@ -5165,6 +5237,20 @@ function closeCellDetails() {
detailCell.value = null;
}
function toggleMongoJsonPreview() {
if (!canShowMongoJsonPreview.value) return;
showMongoJsonPreview.value = !showMongoJsonPreview.value;
if (showMongoJsonPreview.value) closeCellDetails();
}
function closeMongoJsonPreview() {
showMongoJsonPreview.value = false;
}
function copyMongoJsonPreview() {
if (mongoJsonPreviewFullText.value) copyText(mongoJsonPreviewFullText.value);
}
function cellDetailEditText(detail: DataGridCellDetail): string {
if (sideDetailJsonView.value && detail.formattedJson) return detail.formattedJson;
return dataGridCellEditorText({
@@ -6349,6 +6435,7 @@ function selectExportMenuItem(value: string) {
// --- Cell selection and detail ---
function showCellDetails(rowIndex: number, colIndex: number) {
closeMongoJsonPreview();
resetDetailEdit();
detailCell.value = { rowIndex, col: colIndex };
activeCellDetailTab.value = defaultCellDetailTab();
@@ -7733,6 +7820,7 @@ const CELL_DETAIL_TABLE_MIN_VISIBLE_ROWS = 1.5;
const CELL_DETAIL_TABLE_HORIZONTAL_SCROLLBAR_HEIGHT = 10;
const CELL_DETAIL_TABLE_MIN_VISIBLE_HEIGHT = Math.ceil(CELL_DETAIL_TABLE_HEADER_HEIGHT + CANVAS_DATA_GRID_ROW_HEIGHT * CELL_DETAIL_TABLE_MIN_VISIBLE_ROWS + CELL_DETAIL_TABLE_HORIZONTAL_SCROLLBAR_HEIGHT);
const DRAWER_MAX_WIDTH = 900;
const MONGO_JSON_PREVIEW_DEFAULT_WIDTH = 420;
function clampCellDetailPanelSize(value: number, layout = cellDetailPanelLayout.value): number {
const min = layout === "bottom" ? CELL_DETAIL_PANEL_MIN_HEIGHT : CELL_DETAIL_PANEL_MIN_WIDTH;
const max = layout === "bottom" ? CELL_DETAIL_PANEL_MAX_HEIGHT : DRAWER_MAX_WIDTH;
@@ -7759,12 +7847,16 @@ function onDdlKeydown(e: KeyboardEvent) {
const ddlLoading = ref(false);
const ddlWidth = ref(settingsStore.editorSettings.tableInfoDrawerWidth);
const detailPanelHeight = ref(settingsStore.editorSettings.cellDetailDrawerWidth);
const mongoJsonPreviewWidth = ref(MONGO_JSON_PREVIEW_DEFAULT_WIDTH);
const ddlWrap = ref(true);
const isResizingDdl = ref(false);
const isResizingMongoJsonPreview = ref(false);
let ddlResizeStartX = 0;
let ddlResizeStartWidth = 0;
let detailResizeStartY = 0;
let detailResizeStartHeight = 0;
let mongoJsonPreviewResizeStartX = 0;
let mongoJsonPreviewResizeStartWidth = 0;
const indexes = ref<IndexInfo[]>([]);
const indexesLoaded = ref(false);
const indexesLoading = ref(false);
@@ -7836,6 +7928,10 @@ const detailPanelStyle = computed(() =>
: { width: `${detailPanelHeight.value}px` },
);
const mongoJsonPreviewStyle = computed(() => ({
width: `${mongoJsonPreviewWidth.value}px`,
}));
const contentGridStyle = computed(() =>
cellDetailPanelIsBottom.value && showCellDetail.value && activeCellDetail.value
? {
@@ -8126,6 +8222,27 @@ function onDetailResizeEnd() {
window.removeEventListener("mouseup", onDetailResizeEnd);
}
function onMongoJsonPreviewResizeStart(event: MouseEvent) {
isResizingMongoJsonPreview.value = true;
mongoJsonPreviewResizeStartX = event.clientX;
mongoJsonPreviewResizeStartWidth = mongoJsonPreviewWidth.value;
document.body.classList.add("select-none", "cursor-col-resize");
window.addEventListener("mousemove", onMongoJsonPreviewResizeMove);
window.addEventListener("mouseup", onMongoJsonPreviewResizeEnd);
}
function onMongoJsonPreviewResizeMove(event: MouseEvent) {
if (!isResizingMongoJsonPreview.value) return;
mongoJsonPreviewWidth.value = clampCellDetailPanelSize(mongoJsonPreviewResizeStartWidth + mongoJsonPreviewResizeStartX - event.clientX, "right");
}
function onMongoJsonPreviewResizeEnd() {
isResizingMongoJsonPreview.value = false;
document.body.classList.remove("select-none", "cursor-col-resize");
window.removeEventListener("mousemove", onMongoJsonPreviewResizeMove);
window.removeEventListener("mouseup", onMongoJsonPreviewResizeEnd);
}
const loadingElapsed = ref(0);
let _loadingFrame: number | undefined;
let _loadingStart = 0;
@@ -8198,6 +8315,7 @@ onUnmounted(() => {
onSearchSplitResizeEnd();
onDdlResizeEnd();
onDetailResizeEnd();
onMongoJsonPreviewResizeEnd();
finishCellSelection();
clearTimeout(highlightedColumnTimer);
clearTimeout(_searchTimer);
@@ -9047,6 +9165,21 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
{{ t("grid.keylessEditWarningHint") }}
</TooltipContent>
</Tooltip>
<Tooltip v-if="canShowMongoJsonPreview">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="sm"
:class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compactDataGridToolbar ? 'data-grid-topbar-action-button--compact' : '', mongoJsonPreviewOpen ? 'text-primary bg-primary/10 hover:bg-primary/15' : '']"
:aria-pressed="mongoJsonPreviewOpen"
@click="toggleMongoJsonPreview"
>
<Code2 class="data-grid-topbar-action-icon w-3 h-3" />
<span class="data-grid-topbar-action-label" :class="{ 'data-grid-topbar-action-label--compact': compactDataGridToolbar }">{{ t("grid.mongoJsonPreview") }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ t("grid.mongoJsonPreview") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compactDataGridToolbar ? 'data-grid-topbar-action-button--compact' : '', isSaving ? '' : '']" :disabled="isSaving" @click="onToolbarRefresh">
@@ -10575,6 +10708,30 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</TabsContent>
</Tabs>
</div>
<!-- MongoDB document JSON preview -->
<div v-else-if="mongoJsonPreviewOpen" class="relative col-start-3 row-start-1 flex min-w-0 flex-col border-l bg-background" :class="{ 'detail-drawer-resizing': isResizingMongoJsonPreview }" :style="mongoJsonPreviewStyle" @contextmenu="onDrawerContextMenu">
<div class="absolute bottom-0 left-0 top-0 z-20 w-1.5 -translate-x-1/2 cursor-col-resize hover:bg-primary/30" @mousedown.prevent="onMongoJsonPreviewResizeStart" />
<div class="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
<Code2 class="h-3.5 w-3.5 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-xs font-medium">{{ t("grid.mongoJsonPreview") }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!mongoJsonPreviewFullText" :title="t('grid.copyJson')" @click="copyMongoJsonPreview">
<Copy class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeMongoJsonPreview">
<X class="h-3 w-3" />
</Button>
</div>
<div v-if="mongoJsonPreviewText" class="flex min-h-0 flex-1 flex-col overflow-hidden p-2">
<div v-if="mongoJsonPreviewUsesCodeEditor" ref="mongoJsonPreviewContainer" data-cell-detail-editor-root class="min-h-0 flex-1 overflow-hidden" />
<template v-else>
<pre class="min-h-0 flex-1 overflow-auto rounded border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap break-words">{{ mongoJsonPreviewText }}</pre>
<div class="mt-1 text-[11px] text-muted-foreground">{{ t("grid.largeValuePreviewHint", { count: mongoJsonPreviewText.length }) }}</div>
</template>
</div>
<div v-else class="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs text-muted-foreground">
{{ t("grid.mongoJsonPreviewEmpty") }}
</div>
</div>
</div>
</div>
</CustomContextMenu>
@@ -69,7 +69,7 @@ import { formatShortcut } from "@/lib/editor/shortcutRegistry";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { chartableColumnIndexes } from "@/lib/dataGrid/chartData";
import * as api from "@/lib/backend/api";
import { buildMongoUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { applyMongoGridChangesToDocument, buildMongoUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import type { SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { useTabScroll } from "@/composables/useTabScroll";
@@ -452,7 +452,26 @@ const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() =
return stmts;
};
return { save, preview, canInsert: false, canDelete: false, supportsInsert: false, readonlyColumns: [target.idColumn], targetLabel: target.collection };
const applySavedChanges: NonNullable<CustomSaveHandler["applySavedChanges"]> = ({ dirtyRows, columns }) => {
const documents = tab.result?.mongo_documents;
if (!documents) return;
// Replace the raw array only after every backend update succeeds, keeping
// the grid and JSON preview atomic when a multi-row save partially fails.
const replacements = new Map<unknown, unknown>();
tab.result!.mongo_documents = documents.map((document, rowIdx) => {
const changes = dirtyRows.get(rowIdx);
if (!changes) return document;
const updated = applyMongoGridChangesToDocument(document, changes, columns);
replacements.set(document, updated);
return updated;
});
if (tab.resultLocalSortOriginalMongoDocuments) {
tab.resultLocalSortOriginalMongoDocuments = tab.resultLocalSortOriginalMongoDocuments.map((document) => replacements.get(document) ?? document);
}
};
return { save, preview, applySavedChanges, canInsert: false, canDelete: false, supportsInsert: false, readonlyColumns: [target.idColumn], targetLabel: target.collection };
});
const resultsPaneOpen = ref(false);
const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size")) || DEFAULT_QUERY_RESULTS_PANE_SIZE);
@@ -1202,6 +1202,7 @@ async function openData() {
tab.resultSortDirection = undefined;
tab.resultSortMode = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultSortedSql = undefined;
tab.resultPageSql = undefined;
tab.resultPageLimit = undefined;