feat(mongo): open object browser from database tree nodes

This commit is contained in:
高瑞强 authored and GitHub committed 2026-08-25 22:15:40 +08:00
1 parent 46111850db
commit bfd7c7def2
29 files changed
+354 -61

No files matched your search

@@ -2248,7 +2248,7 @@ defineExpose({
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
<template v-else-if="activeTab.mode === 'mongo'">
<div class="flex-1 min-h-0">
<DocumentBrowser ref="documentBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" :table-meta="activeTab.tableMeta" />
<DocumentBrowser ref="documentBrowserRef" :key="`${activeTab.id}:${activeTab.sql}`" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" :table-meta="activeTab.tableMeta" />
</div>
</template>
@@ -9,3 +9,9 @@ describe("ContentArea completed query output", () => {
expect(contentAreaSource).toContain('emit("update:activeOutputView", result ? defaultViewForResult(result) : "summary")');
});
});
describe("ContentArea Mongo tab reuse", () => {
it("remounts the document browser when the active tab changes collections", () => {
expect(contentAreaSource).toContain(':key="`${activeTab.id}:${activeTab.sql}`"');
});
});
@@ -119,6 +119,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { filterSchemaNamesForConnection } from "@/lib/database/visibleDatabases";
import {
buildObjectBrowserRows,
buildMongoObjectBrowserRows,
countObjectBrowserRowsByFilter,
formatObjectBrowserBytes,
formatObjectBrowserCount,
@@ -139,6 +140,7 @@ import { isSourceOnlyObjectBrowserRow, resolveRowClickAction, shouldDeferSingleC
import { objectBrowserTableSelectionAnchor, objectBrowserTableSelectionRange } from "@/lib/table/objectBrowserSelection";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
import { visibleMongoCollections } from "@/lib/sidebar/mongoCollectionMutation";
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
@@ -237,6 +239,9 @@ const tableMetadataCapabilities = computed<TableMetadataCapabilities>(() => getT
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
const isGaussdbM = computed(() => effectiveDatabaseType.value === "gaussdb" && props.connection.driver_profile?.toLowerCase() === "gaussdb-m");
const isVictoriaMetrics = computed(() => effectiveDatabaseType.value === "victoriametrics");
const isMongodb = computed(() => props.connection.db_type === "mongodb");
const showObjectRowStats = computed(() => !isMongodb.value);
const showObjectSizeStats = computed(() => !isVictoriaMetrics.value && !isMongodb.value);
const objectRowsLabel = computed(() => t(isVictoriaMetrics.value ? "objects.series" : "objects.rows"));
function toggleTableDdlWordWrap() {
@@ -502,8 +507,9 @@ function toggleCheckboxColumn() {
const objectBrowserColumns = computed<ObjectBrowserColumnKey[]>(() => {
const columns: ObjectBrowserColumnKey[] = [];
if (showCheckboxColumn.value) columns.push("select");
columns.push("name", "type", "estimatedRows");
if (!isVictoriaMetrics.value) columns.push("totalBytes");
columns.push("name", "type");
if (showObjectRowStats.value) columns.push("estimatedRows");
if (showObjectSizeStats.value) columns.push("totalBytes");
if (hasCreatedAt.value) columns.push("created_at");
if (hasUpdatedAt.value) columns.push("updated_at");
columns.push("comment");
@@ -637,18 +643,23 @@ function iconFor(row: ObjectBrowserRow) {
return Table2;
}
function typeLabel(type: ObjectBrowserRow["type"]) {
if (type === "MATERIALIZED_VIEW") return t("common.materializedView");
if (type === "VIEW") return t("objects.view");
if (type === "PROCEDURE") return t("objects.procedure");
if (type === "FUNCTION") return t("objects.function");
if (type === "TRIGGER") return t("objects.trigger");
if (type === "EVENT") return t("tree.events");
if (type === "SEQUENCE") return t("objects.sequence");
if (type === "PACKAGE") return t("objects.package");
if (type === "PACKAGE_BODY") return t("objects.packageBody");
if (type === "TYPE") return t("objects.typeDefinition");
if (type === "TYPE_BODY") return t("objects.typeBody");
function typeLabel(row: ObjectBrowserRow) {
if (isMongodb.value) {
if (row.collectionKind === "view" || row.type === "VIEW") return t("objects.view");
if (row.collectionKind === "timeseries") return t("objects.timeseries");
return t("objects.collection");
}
if (row.type === "MATERIALIZED_VIEW") return t("common.materializedView");
if (row.type === "VIEW") return t("objects.view");
if (row.type === "PROCEDURE") return t("objects.procedure");
if (row.type === "FUNCTION") return t("objects.function");
if (row.type === "TRIGGER") return t("objects.trigger");
if (row.type === "EVENT") return t("tree.events");
if (row.type === "SEQUENCE") return t("objects.sequence");
if (row.type === "PACKAGE") return t("objects.package");
if (row.type === "PACKAGE_BODY") return t("objects.packageBody");
if (row.type === "TYPE") return t("objects.typeDefinition");
if (row.type === "TYPE_BODY") return t("objects.typeBody");
return t("objects.table");
}
@@ -667,8 +678,9 @@ function toggleSort(key: ObjectBrowserSortKey) {
}
const sortKeyOptions = computed<ObjectBrowserSortKey[]>(() => {
const options: ObjectBrowserSortKey[] = ["name", "type", "estimatedRows"];
if (!isVictoriaMetrics.value) options.push("totalBytes");
const options: ObjectBrowserSortKey[] = ["name", "type"];
if (showObjectRowStats.value) options.push("estimatedRows");
if (showObjectSizeStats.value) options.push("totalBytes");
if (hasCreatedAt.value) options.push("created_at");
if (hasUpdatedAt.value) options.push("updated_at");
options.push("comment");
@@ -893,7 +905,7 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction)
void openTypeInfo(row);
break;
case "open-table":
emit("openTable", { tableName: row.name, schema: row.schema, catalog: props.catalog });
emit("openTable", { tableName: row.name, schema: row.schema, tableType: objectBrowserOpenTableType(row), catalog: props.catalog });
break;
case "open-source":
void (row.type === "EVENT" ? openEventEditor(row) : openSource(row));
@@ -935,7 +947,7 @@ function onRowClick(row: ObjectBrowserRow, event: MouseEvent) {
}
// Single click: defer when the row has a distinct double-click action so a
// following second click can cancel it (e.g. TABLE single→table-info, double→open-table).
if (shouldDeferSingleClick(row, action)) {
if (shouldDeferSingleClick(row, action, effectiveDatabaseType.value)) {
if (singleClickTimer) clearTimeout(singleClickTimer);
singleClickTimer = setTimeout(() => {
singleClickTimer = null;
@@ -1614,8 +1626,14 @@ async function saveFileContent(content: string, defaultFileName: string, filterN
}
}
function objectBrowserOpenTableType(row: ObjectBrowserRow): string {
if (row.collectionKind === "view") return "VIEW";
if (row.collectionKind === "timeseries") return "TIMESERIES";
return row.type;
}
function openViewData(row: ObjectBrowserRow) {
emit("openTable", { tableName: row.name, schema: row.schema, tableType: row.type, catalog: props.catalog });
emit("openTable", { tableName: row.name, schema: row.schema, tableType: objectBrowserOpenTableType(row), catalog: props.catalog });
}
function openStructureEditor(row: ObjectBrowserRow) {
@@ -2178,7 +2196,7 @@ function copySelectedTablesToClipboard() {
}
function canPasteTableClipboard(): boolean {
return !isVictoriaMetrics.value && tableClipboardMatchesTarget(normalizedObjectBrowserTableClipboardEntries(), pasteTableTargetContext());
return !isVictoriaMetrics.value && !isMongodb.value && tableClipboardMatchesTarget(normalizedObjectBrowserTableClipboardEntries(), pasteTableTargetContext());
}
function normalizedObjectBrowserTableClipboardEntries() {
@@ -2687,17 +2705,11 @@ async function loadObjects(options?: { allowCached?: boolean }) {
loadingObjects.value = true;
rows.value = [];
try {
const objects: ObjectInfo[] = await api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema, undefined, undefined, undefined, undefined, request.scope.catalog);
const nextRows = props.connection.db_type === "mongodb" ? await loadMongoObjectBrowserRows(request.scope.connectionId, request.scope.database) : await loadSqlObjectBrowserRows(request);
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
const nextRows = buildObjectBrowserRows({
objects,
database: request.scope.database,
fallbackSchema: request.scope.schema,
rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value),
});
applyObjectBrowserRows(nextRows);
const cachedAt = cacheObjectBrowserRows(cacheWriteToken, nextRows);
void loadObjectStatistics(request, cacheWriteToken, cachedAt);
if (props.connection.db_type !== "mongodb") void loadObjectStatistics(request, cacheWriteToken, cachedAt);
} catch (e: any) {
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
error.value = translateBackendError(t, e);
@@ -2706,6 +2718,21 @@ async function loadObjects(options?: { allowCached?: boolean }) {
}
}
async function loadSqlObjectBrowserRows(request: ObjectBrowserRowsLoadHandle) {
const objects: ObjectInfo[] = await api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema, undefined, undefined, undefined, undefined, request.scope.catalog);
return buildObjectBrowserRows({
objects,
database: request.scope.database,
fallbackSchema: request.scope.schema,
rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value),
});
}
async function loadMongoObjectBrowserRows(connectionId: string, database: string) {
const collections = await api.mongoListCollections(connectionId, database);
return buildMongoObjectBrowserRows({ collections: visibleMongoCollections(collections), database });
}
async function loadObjectStatistics(request: ObjectBrowserRowsLoadHandle, cacheWriteToken: ObjectBrowserRowsCacheWriteToken, cachedAt: number | undefined) {
if (!rows.value.some((row) => row.type === "TABLE")) return;
try {
@@ -2768,7 +2795,9 @@ function filterCount(filter: ObjectFilter) {
function filterLabel(filter: ObjectFilter) {
const key =
filter === "tables"
? "objects.tables"
? isMongodb.value
? "objects.collections"
: "objects.tables"
: filter === "views"
? "objects.views"
: filter === "materializedViews"
@@ -3050,6 +3079,13 @@ function getTypeMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
}
function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
if (isMongodb.value) {
return [
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
{ label: "", separator: true },
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
];
}
if (item.type === "TABLE") return getTableMenuItems(item);
if (item.type === "VIEW" || item.type === "MATERIALIZED_VIEW") return getViewMenuItems(item);
if (item.type === "EVENT") return getEventMenuItems(item);
@@ -3073,7 +3109,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<div class="flex min-w-0 flex-1 items-center gap-2">
<div class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" data-object-search-input class="h-7 pl-8 pr-6 text-xs" :placeholder="t('objects.search')" @keydown="onSearchKeydown" />
<Input v-model="search" data-object-search-input class="h-7 pl-8 pr-6 text-xs" :placeholder="isMongodb ? t('objects.searchCollections') : t('objects.search')" @keydown="onSearchKeydown" />
<button v-if="search" type="button" class="absolute right-1.5 top-1/2 flex h-4 w-4 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('common.clear')" @click="clearObjectSearch">
<X class="h-3 w-3" />
</button>
@@ -3150,23 +3186,23 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<div class="min-w-0 flex-1 truncate text-muted-foreground">
{{ t("objects.selectedTables", { count: selectedTableCount }) }}
</div>
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openBatchDatabaseExport">
<Button v-if="showObjectSizeStats" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openBatchDatabaseExport">
<Upload class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.exportSelected") }}
</Button>
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="copySelectedTablesToClipboard">
<Button v-if="showObjectSizeStats" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="copySelectedTablesToClipboard">
<Clipboard class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.copyTableSelected") }}
</Button>
<Button v-if="!isVictoriaMetrics && supportsTruncateTable" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchTruncateTables">
<Button v-if="showObjectSizeStats && supportsTruncateTable" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchTruncateTables">
<Scissors class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.truncateSelected") }}
</Button>
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchEmptyTables">
<Button v-if="showObjectSizeStats" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchEmptyTables">
<Eraser class="mr-1.5 h-3.5 w-3.5" />
{{ t("contextMenu.batchEmpty", { count: selectedTableCount }) }}
</Button>
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchDropTables">
<Button v-if="showObjectSizeStats" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchDropTables">
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.dropSelected") }}
</Button>
@@ -3217,7 +3253,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<GripVertical class="h-3 w-3" />
</div>
</div>
<div class="relative flex min-w-0 items-center">
<div v-if="showObjectRowStats" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')">
<span class="truncate">{{ objectRowsLabel }}</span>
<component :is="sortIconFor('estimatedRows')" v-if="sortIconFor('estimatedRows')" class="h-3 w-3 shrink-0" />
@@ -3230,7 +3266,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<GripVertical class="h-3 w-3" />
</div>
</div>
<div v-if="!isVictoriaMetrics" class="relative flex min-w-0 items-center">
<div v-if="showObjectSizeStats" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')">
<span class="truncate">{{ t("objects.size") }}</span>
<component :is="sortIconFor('totalBytes')" v-if="sortIconFor('totalBytes')" class="h-3 w-3 shrink-0" />
@@ -3318,11 +3354,11 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
{{ t("objects.partitions", { count: item.partitionCount }) }}
</span>
</div>
<div class="truncate text-xs text-muted-foreground">{{ typeLabel(item.type) }}</div>
<div class="truncate text-xs tabular-nums text-muted-foreground" :title="item.estimatedRows == null ? '' : formatObjectBrowserCount(item.estimatedRows)">
<div class="truncate text-xs text-muted-foreground">{{ typeLabel(item) }}</div>
<div v-if="showObjectRowStats" class="truncate text-xs tabular-nums text-muted-foreground" :title="item.estimatedRows == null ? '' : formatObjectBrowserCount(item.estimatedRows)">
{{ formatObjectBrowserCount(item.estimatedRows) }}
</div>
<div v-if="!isVictoriaMetrics" class="truncate text-xs tabular-nums text-muted-foreground" :title="item.totalBytes == null ? '' : formatObjectBrowserBytes(item.totalBytes)">
<div v-if="showObjectSizeStats" class="truncate text-xs tabular-nums text-muted-foreground" :title="item.totalBytes == null ? '' : formatObjectBrowserBytes(item.totalBytes)">
{{ formatObjectBrowserBytes(item.totalBytes) }}
</div>
<div v-if="hasCreatedAt" class="truncate text-xs tabular-nums text-muted-foreground" :title="formatObjectBrowserTimestamp(item.created_at)">
@@ -3363,11 +3399,11 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
<span class="w-full truncate text-sm font-medium leading-tight text-foreground">{{ item.displayName }}</span>
<div class="flex items-center gap-1.5">
<span class="text-xs text-muted-foreground">{{ typeLabel(item.type) }}</span>
<span v-if="item.estimatedRows != null && item.estimatedRows > 0" class="object-browser-stat-badge object-browser-stat-badge-rows rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-primary">{{
<span class="text-xs text-muted-foreground">{{ typeLabel(item) }}</span>
<span v-if="showObjectRowStats && item.estimatedRows != null && item.estimatedRows > 0" class="object-browser-stat-badge object-browser-stat-badge-rows rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-primary">{{
formatObjectBrowserCount(item.estimatedRows)
}}</span>
<span v-if="!isVictoriaMetrics && item.totalBytes != null && item.totalBytes > 0" class="object-browser-stat-badge object-browser-stat-badge-bytes rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">{{
<span v-if="showObjectSizeStats && item.totalBytes != null && item.totalBytes > 0" class="object-browser-stat-badge object-browser-stat-badge-bytes rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">{{
formatObjectBrowserBytes(item.totalBytes)
}}</span>
</div>
@@ -5244,6 +5244,9 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
if (node.type === "redis-db" || node.type === "mongo-db" || node.type === "vector-database") {
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (node.type === "mongo-db" && canOpenObjectBrowser.value) {
items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties });
}
if (!isNodeDefaultDatabase.value) {
items.push({ label: t("contextMenu.setDefaultDatabase"), action: setNodeAsDefaultDatabase, icon: Database });
} else {