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 {
@@ -1,6 +1,7 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DataTabReuseMode } from "@/lib/tabs/dataTabReuseMode";
import { buildMongoObjectBrowserRows } from "@/lib/table/objectBrowserRows";
import type { QueryResult } from "@/types/database";
const mocks = vi.hoisted(() => ({
@@ -233,6 +234,65 @@ describe("useNavigationTargets with the real query store", () => {
expect(queryStore.activeTabId).toBe(firstTabId);
});
it("reuses only the same MongoDB collection in same-table mode", async () => {
mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mongodb" }));
const { navigation, queryStore } = await setupNavigation();
const rows = buildMongoObjectBrowserRows({ collections: [{ name: " users " }, { name: "users" }], database: "app" });
for (const row of rows) {
await navigation.openObjectBrowserTableTarget({ connectionId: "connection-1", database: "app", tableName: row.name, tableType: row.type });
}
await navigation.openObjectBrowserTableTarget({ connectionId: "connection-1", database: "app", tableName: rows[0]!.name, tableType: rows[0]!.type });
expect(queryStore.tabs).toHaveLength(2);
expect(new Set(queryStore.tabs.map((tab) => tab.id))).toHaveLength(2);
expect(queryStore.tabs.map((tab) => tab.sql)).toEqual([" users ", "users"]);
expect(queryStore.tabs.map((tab) => tab.tableMeta?.tableName)).toEqual([" users ", "users"]);
expect(queryStore.activeTabId).toBe(queryStore.tabs[0]?.id);
});
it("always creates a new MongoDB collection tab in always-new mode", async () => {
mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mongodb" }));
mocks.settingsStore.editorSettings.dataTabReuseMode = "always-new";
const { navigation, queryStore } = await setupNavigation();
const target = { connectionId: "connection-1", database: "app", tableName: "users", tableType: "TABLE" };
await navigation.openObjectBrowserTableTarget(target);
await navigation.openObjectBrowserTableTarget(target);
expect(queryStore.tabs).toHaveLength(2);
expect(new Set(queryStore.tabs.map((tab) => tab.id))).toHaveLength(2);
});
it("reuses the active safe MongoDB collection tab in active-tab mode", async () => {
mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mongodb" }));
mocks.settingsStore.editorSettings.dataTabReuseMode = "active-tab";
const { navigation, queryStore } = await setupNavigation();
const target = { connectionId: "connection-1", database: "app", tableName: "users", tableType: "TABLE" };
await navigation.openObjectBrowserTableTarget(target);
const originalTabId = queryStore.activeTabId;
await navigation.openObjectBrowserTableTarget({ ...target, tableName: "orders" });
expect(queryStore.tabs).toHaveLength(1);
expect(queryStore.activeTabId).toBe(originalTabId);
expect(queryStore.tabs[0]).toMatchObject({ title: "app.orders", sql: "orders", tableMeta: { tableName: "orders" } });
});
it("does not reuse an unsafe active MongoDB collection tab", async () => {
mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mongodb" }));
mocks.settingsStore.editorSettings.dataTabReuseMode = "active-tab";
const { navigation, queryStore } = await setupNavigation();
const target = { connectionId: "connection-1", database: "app", tableName: "users", tableType: "TABLE" };
await navigation.openObjectBrowserTableTarget(target);
queryStore.tabs[0]!.pinned = true;
await navigation.openObjectBrowserTableTarget({ ...target, tableName: "orders" });
expect(queryStore.tabs).toHaveLength(2);
expect(queryStore.tabs.map((tab) => tab.sql)).toEqual(["users", "orders"]);
});
it("reuses a restored legacy MySQL tab when the same table is opened from the sidebar", async () => {
mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mysql" }));
mocks.loadOpenTabsState.mockResolvedValue({
@@ -1,7 +1,7 @@
import * as api from "@/lib/backend/api";
import { connectionObjectTreeNodeSchema, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
import { invalidateTableMetadataCache, loadTableMetadata } from "@/lib/metadata/tableMetadataCache";
import { canApplyDataTabMetadata } from "@/lib/sidebar/dataTabOpenPolicy";
import { canApplyDataTabMetadata, canReuseActiveMongoTab, type DataTabReuseMode } from "@/lib/sidebar/dataTabOpenPolicy";
import { isNoSnapshotErrorResult, isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
@@ -26,6 +26,30 @@ export type NavigationTarget = {
whereInput?: string;
};
function openMongoCollectionTarget(target: NavigationTarget, reuseMode: DataTabReuseMode) {
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
connectionStore.activeConnectionId = target.connectionId;
const tabTitle = `${target.database}.${target.tableName}`;
const sameCollectionTab = reuseMode === "always-new" ? undefined : queryStore.tabs.find((tab) => tab.mode === "mongo" && tab.connectionId === target.connectionId && tab.database === target.database && tab.sql === target.tableName);
const activeTab = queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId);
const reusableActiveTab = reuseMode === "active-tab" && !sameCollectionTab && canReuseActiveMongoTab(activeTab, target) ? activeTab : undefined;
const reusableTab = sameCollectionTab ?? reusableActiveTab;
const tabId = reusableTab?.id ?? queryStore.createTab(target.connectionId, target.database, tabTitle, "mongo", undefined, undefined, undefined, { forceNew: true });
if (reusableTab) {
queryStore.switchTab(tabId);
reusableTab.title = tabTitle;
}
queryStore.updateSql(tabId, target.tableName);
queryStore.setTableMeta(tabId, {
database: target.database,
tableName: target.tableName,
tableType: target.tableType || "TABLE",
columns: [],
primaryKeys: [],
});
}
async function openTableTarget(target: NavigationTarget, options: { tableInfoTab?: TableInfoTab } = {}) {
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
@@ -34,6 +58,10 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
connectionStore.activeConnectionId = target.connectionId;
const config = connectionStore.getConfig(target.connectionId);
if (config?.db_type === "mongodb") {
openMongoCollectionTarget(target, settingsStore.editorSettings.dataTabReuseMode);
return;
}
const tableSchema = connectionObjectTreeNodeSchema(config, target.database, target.schema);
const tabTitle = target.catalog ? `${target.catalog}.${tableSchema || target.database}.${target.tableName}` : tableSchema ? `${tableSchema}.${target.tableName}` : target.tableName;
if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") {
@@ -287,6 +315,10 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value:
const { openData } = useSidebarDataOpenRuntime();
async function openObjectBrowserTableTarget(target: NavigationTarget) {
if (connectionStore.getConfig(target.connectionId)?.db_type === "mongodb") {
openMongoCollectionTarget(target, settingsStore.editorSettings.dataTabReuseMode);
return;
}
if (settingsStore.editorSettings.dataTabReuseMode === "always-new") {
await openTableTarget(target);
return;
+4
View File
@@ -3620,14 +3620,17 @@ export default {
objects: {
all: "All",
tables: "Tables",
collections: "Collections",
views: "Views",
procedures: "Procedures",
functions: "Functions",
sequences: "Sequences",
packages: "Packages",
table: "Table",
collection: "Collection",
partitions: "{count} partitions",
view: "View",
timeseries: "Time Series",
procedure: "Procedure",
function: "Function",
trigger: "Trigger",
@@ -3668,6 +3671,7 @@ export default {
loadingSchemas: "Loading schemas...",
schema: "Schema",
search: "Search tables, views, functions, sequences, or procedures...",
searchCollections: "Search collections...",
loading: "Loading objects...",
empty: "No objects found",
selectedTables: "{count} tables selected",
+4
View File
@@ -3440,14 +3440,17 @@ export default withEnglishFallback({
objects: {
all: "Todos",
tables: "Tablas",
collections: "Colecciones",
views: "Vistas",
procedures: "Procedimientos",
functions: "Funciones",
sequences: "Secuencias",
packages: "Paquetes",
table: "Tabla",
collection: "Colección",
partitions: "{count} particiones",
view: "Vista",
timeseries: "Serie temporal",
procedure: "Procedimiento",
function: "Función",
sequence: "Secuencia",
@@ -3473,6 +3476,7 @@ export default withEnglishFallback({
loadingSchemas: "Cargando esquemas...",
schema: "Esquema",
search: "Buscar tablas, vistas, funciones, secuencias o procedimientos...",
searchCollections: "Buscar colecciones...",
loading: "Cargando objetos...",
empty: "No se encontraron objetos",
selectedTables: "{count} tablas seleccionadas",
+4
View File
@@ -3438,14 +3438,17 @@ export default withEnglishFallback({
objects: {
all: "Tutti",
tables: "Tabelle",
collections: "Collezioni",
views: "Viste",
procedures: "Procedure",
functions: "Funzioni",
sequences: "Sequenze",
packages: "Pacchetti",
table: "Tabella",
collection: "Collezione",
partitions: "{count} partizioni",
view: "Vista",
timeseries: "Serie temporale",
procedure: "Procedura",
function: "Funzione",
sequence: "Sequenza",
@@ -3471,6 +3474,7 @@ export default withEnglishFallback({
loadingSchemas: "Caricamento schemi...",
schema: "Schema",
search: "Cerca tabelle, viste, funzioni, sequenze o procedure...",
searchCollections: "Cerca collezioni...",
loading: "Caricamento oggetti...",
empty: "Nessun oggetto trovato",
selectedTables: "{count} tabelle selezionate",
+4
View File
@@ -3500,14 +3500,17 @@ export default withEnglishFallback({
objects: {
all: "すべて",
tables: "テーブル",
collections: "コレクション",
views: "ビュー",
procedures: "プロシージャ",
functions: "関数",
sequences: "シーケンス",
packages: "パッケージ",
table: "テーブル",
collection: "コレクション",
partitions: "{count}パーティション",
view: "ビュー",
timeseries: "時系列",
procedure: "プロシージャ",
function: "関数",
sequence: "シーケンス",
@@ -3533,6 +3536,7 @@ export default withEnglishFallback({
loadingSchemas: "スキーマを読み込み中...",
schema: "スキーマ",
search: "テーブル、ビュー、関数、シーケンス、プロシージャを検索...",
searchCollections: "コレクションを検索...",
loading: "オブジェクトを読み込み中...",
empty: "オブジェクトが見つかりません",
selectedTables: "{count}テーブル選択中",
+4
View File
@@ -3401,14 +3401,17 @@ export default withEnglishFallback({
objects: {
all: "전체",
tables: "테이블",
collections: "컬렉션",
views: "뷰",
procedures: "프로시저",
functions: "함수",
sequences: "시퀀스",
packages: "패키지",
table: "테이블",
collection: "컬렉션",
partitions: "파티션 {count}개",
view: "뷰",
timeseries: "시계열",
procedure: "프로시저",
function: "함수",
trigger: "트리거",
@@ -3438,6 +3441,7 @@ export default withEnglishFallback({
loadingSchemas: "스키마를 불러오는 중...",
schema: "스키마",
search: "테이블, 뷰, 함수, 시퀀스 또는 프로시저 검색...",
searchCollections: "컬렉션 검색...",
loading: "객체를 불러오는 중...",
empty: "객체가 없습니다",
selectedTables: "테이블 {count}개 선택됨",
+4
View File
@@ -3440,14 +3440,17 @@ export default withEnglishFallback({
objects: {
all: "Todos",
tables: "Tabelas",
collections: "Coleções",
views: "Visões",
procedures: "Procedimentos",
functions: "Funções",
sequences: "Sequências",
packages: "Pacotes",
table: "Tabela",
collection: "Coleção",
partitions: "{count} partições",
view: "Visão",
timeseries: "Série temporal",
procedure: "Procedimento",
function: "Função",
sequence: "Sequência",
@@ -3473,6 +3476,7 @@ export default withEnglishFallback({
loadingSchemas: "Carregando schemas...",
schema: "Schema",
search: "Pesquisar tabelas, visões, funções, sequências ou procedimentos...",
searchCollections: "Pesquisar coleções...",
loading: "Carregando objetos...",
empty: "Nenhum objeto encontrado",
selectedTables: "{count} tabelas selecionadas",
+4
View File
@@ -3607,14 +3607,17 @@ export default withEnglishFallback({
objects: {
all: "全部",
tables: "表",
collections: "集合",
views: "视图",
procedures: "存储过程",
functions: "函数",
sequences: "序列",
packages: "包",
table: "表",
collection: "集合",
partitions: "{count} 个分区",
view: "视图",
timeseries: "时间序列",
procedure: "存储过程",
function: "函数",
trigger: "触发器",
@@ -3654,6 +3657,7 @@ export default withEnglishFallback({
loadingSchemas: "加载 Schema...",
schema: "Schema",
search: "搜索表、视图、函数、序列或存储过程...",
searchCollections: "搜索集合...",
loading: "正在加载对象...",
empty: "没有找到对象",
selectedTables: "已选择 {count} 张表",
+4
View File
@@ -3186,14 +3186,17 @@ export default withEnglishFallback({
objects: {
all: "全部",
tables: "資料表",
collections: "集合",
views: "檢視",
procedures: "預存程序",
functions: "函式",
sequences: "序列",
packages: "套件",
table: "資料表",
collection: "集合",
partitions: "{count} 個分割區",
view: "檢視",
timeseries: "時間序列",
procedure: "預存程序",
function: "函式",
sequence: "序列",
@@ -3219,6 +3222,7 @@ export default withEnglishFallback({
loadingSchemas: "載入 Schema……",
schema: "Schema",
search: "搜尋資料表、檢視、函式、序列或預存程序……",
searchCollections: "搜尋集合...",
loading: "正在載入物件……",
empty: "沒有找到物件",
selectedTables: "已選擇 {count} 張資料表",
@@ -9,6 +9,8 @@ import {
supportsConnectionDatabaseBrowser,
supportsDatabaseNameCompletion,
supportsDatabaseSchemaQualifier,
supportsObjectBrowser,
supportsObjectBrowserTreeNode,
supportsQueryTargetDatabaseListing,
supportsQueryEditorBlockComments,
supportsSqlInListPaste,
@@ -50,6 +52,17 @@ describe("connection database browser", () => {
it("follows object browser support without enabling unsupported connection types", () => {
expect(supportsConnectionDatabaseBrowser("postgres")).toBe(true);
expect(supportsConnectionDatabaseBrowser("redis")).toBe(false);
expect(supportsConnectionDatabaseBrowser("mongodb")).toBe(false);
});
});
describe("object browser tree nodes", () => {
it("opens MongoDB object browser from mongo-db nodes without the SQL database list", () => {
expect(supportsObjectBrowser("mongodb")).toBe(true);
expect(supportsObjectBrowserTreeNode("mongodb", "mongo-db")).toBe(true);
expect(supportsObjectBrowserTreeNode("mongodb", "database")).toBe(false);
expect(supportsObjectBrowserTreeNode("mysql", "database")).toBe(true);
expect(supportsObjectBrowserTreeNode("mysql", "mongo-db")).toBe(false);
});
});
@@ -127,6 +127,14 @@ describe("treeNodeClick", () => {
expect(shouldOpenObjectBrowserOnSingleClick("table", true)).toBe(false);
expect(shouldOpenObjectBrowserOnSingleClick("database", true)).toBe(true);
expect(shouldOpenObjectBrowserOnSingleClick("database", false)).toBe(false);
expect(shouldOpenObjectBrowserOnSingleClick("mongo-db", true)).toBe(true);
expect(shouldOpenObjectBrowserOnSingleClick("mongo-db", false)).toBe(false);
});
it("opens the MongoDB object browser from mongo-db nodes like SQL databases", () => {
expect(treeNodeRowAction("mongo-db", true, "single", "mongodb", true, true)).toBe("open-object-browser-and-expand");
expect(treeNodeRowAction("mongo-db", true, "single", "mongodb", false, true)).toBe("toggle");
expect(treeNodeRowDoubleClickAction("mongo-db", true, "single", true, "mongodb")).toBe("open-object-browser");
});
it("expands package containers while preserving source behavior for leaf packages", () => {
@@ -18,6 +18,12 @@ describe("ObjectBrowser context watcher", () => {
expect(watcher).toContain("reload({ allowCachedObjects: true, contextEpoch })");
});
it("loads MongoDB collections through the existing objects view", () => {
expect(objectBrowserSource).toContain("api.mongoListCollections");
expect(objectBrowserSource).toContain("buildMongoObjectBrowserRows");
expect(objectBrowserSource).toContain("objects.searchCollections");
});
it("ignores connection object replacement when the context values stay unchanged", async () => {
const connection = ref({ id: "connection-1" });
const database = ref("database-1");
@@ -177,6 +177,14 @@ describe("shouldDeferSingleClick", () => {
expect(shouldDeferSingleClick(tableRow, "open-table")).toBe(false);
});
it("opens MongoDB collections instead of SQL table-info", () => {
expect(singleClickRowAction(tableRow, "mongodb")).toBe("open-table");
expect(doubleClickRowAction(tableRow, "mongodb")).toBe("open-table");
expect(singleClickRowAction(viewRow, "mongodb")).toBe("open-table");
expect(doubleClickRowAction(viewRow, "mongodb")).toBe("open-table");
expect(shouldDeferSingleClick(tableRow, "open-table", "mongodb")).toBe(false);
});
it("handles null/undefined row", () => {
expect(shouldDeferSingleClick(null, "table-info")).toBe(false);
expect(shouldDeferSingleClick(undefined, "open-source")).toBe(false);
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { orderItemsByPinnedTreeNodeOrder, removePinnedTreeNodesFromOrder, treeNodePinIdentity, treeNodePinKey } from "@/lib/app/pinnedItems";
import { buildObjectBrowserRows, canonicalizeObjectBrowserPinnedTreeNodeIdentity, objectBrowserRowLegacyPinnedTreeNodeIds, objectBrowserRowMatchesPinnedTreeNode, sortObjectBrowserRows, type ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
import { buildMongoObjectBrowserRows, buildObjectBrowserRows, canonicalizeObjectBrowserPinnedTreeNodeIdentity, objectBrowserRowLegacyPinnedTreeNodeIds, objectBrowserRowMatchesPinnedTreeNode, sortObjectBrowserRows, type ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
import type { TreeNode } from "@/types/database";
describe("buildObjectBrowserRows", () => {
@@ -119,3 +119,32 @@ describe("Object Browser pinned ordering", () => {
expect(objectBrowserRowMatchesPinnedTreeNode(routineRow, treeNodePinIdentity({ ...routineNode, id: "conn:app:public:functions:run:integer", label: "run(integer)", signature: "integer" }), context)).toBe(true);
});
});
describe("buildMongoObjectBrowserRows", () => {
it("maps collection kinds and keeps collection names searchable", () => {
const rows = buildMongoObjectBrowserRows({
collections: [
{ name: "users", kind: "collection" },
{ name: "active_users", kind: "view" },
{ name: "metrics", kind: "timeseries" },
],
database: "app",
});
expect(rows.map((row) => ({ name: row.name, type: row.type, collectionKind: row.collectionKind }))).toEqual([
{ name: "users", type: "TABLE", collectionKind: "collection" },
{ name: "active_users", type: "VIEW", collectionKind: "view" },
{ name: "metrics", type: "TABLE", collectionKind: "timeseries" },
]);
});
it("preserves distinct MongoDB collection identifiers verbatim", () => {
const rows = buildMongoObjectBrowserRows({
collections: [{ name: " users " }, { name: "users" }, { name: " " }, { name: "" }],
database: "app",
});
expect(rows.map((row) => row.name)).toEqual([" users ", "users", " "]);
expect(new Set(rows.map((row) => row.id))).toHaveLength(3);
});
});
@@ -216,11 +216,13 @@ export function supportsObjectBrowser(dbType?: DatabaseType): boolean {
}
export function supportsConnectionDatabaseBrowser(dbType?: DatabaseType): boolean {
return supportsObjectBrowser(dbType);
// MongoDB reuses the object browser for collections, not the SQL database list.
return supportsObjectBrowser(dbType) && dbType !== "mongodb";
}
export function supportsObjectBrowserTreeNode(dbType: DatabaseType | undefined, nodeType: TreeNodeType): boolean {
if (!supportsObjectBrowser(dbType)) return false;
if (dbType === "mongodb") return nodeType === "mongo-db";
if (nodeType === "database" && usesDatabaseObjectTreeMode(dbType)) return true;
if (nodeType === "database" && isSchemaAware(dbType) && dbType !== "sqlserver") return false;
return nodeType === "database" || nodeType === "schema" || nodeType === "object-browser";
@@ -22,6 +22,7 @@ import {
snapshotMongoIndexSpec,
toMongoCollectionKind,
toMongoIndexRow,
visibleMongoCollections,
type MongoCreateIndexForm,
type MongoIndexSpecSource,
} from "../mongoCollectionMutation";
@@ -94,6 +95,20 @@ describe("toMongoCollectionKind", () => {
});
});
describe("visibleMongoCollections", () => {
it("hides GridFS buckets and their backing collections", () => {
expect(
visibleMongoCollections([
{ name: "users", kind: "collection" },
{ name: "fs", kind: "bucket", bucketName: "fs" },
{ name: "fs.files", kind: "collection" },
{ name: "fs.chunks", kind: "collection" },
{ name: "reports", kind: "view" },
]).map((collection) => collection.name),
).toEqual(["users", "reports"]);
});
});
describe("isProtectedMongoIndex", () => {
it("protects the default index by name or primary metadata", () => {
expect(isProtectedMongoIndex({ name: "_id_", is_primary: false })).toBe(true);
@@ -34,8 +34,8 @@ export function dataTabOpenModeFromTreeClick(type: TreeNodeType, event: Omit<Sho
return matchesModifierOnlyShortcut(event, shortcut) ? "new-tab" : "default";
}
function isSameDatabase(tab: DataTabLike, target: Pick<DataTabTarget, "connectionId" | "database">): boolean {
return tab.mode === "data" && tab.connectionId === target.connectionId && tab.database === target.database;
function isSameDatabase(tab: DataTabLike, target: Pick<DataTabTarget, "connectionId" | "database">, mode: "data" | "mongo" = "data"): boolean {
return tab.mode === mode && tab.connectionId === target.connectionId && tab.database === target.database;
}
function dataTabCatalog(tab: DataTabLike): string {
@@ -47,8 +47,16 @@ function isSameTable(tab: DataTabLike, target: DataTabTarget): boolean {
return isSameDatabase(tab, target) && dataTabCatalog(tab) === (target.catalog || "") && tabSchema === (target.schema || "") && (tab.tableMeta?.tableName || tab.title) === target.tableName;
}
function canReuseActiveTableTab(tab: DataTabLike | undefined, target: DataTabTarget, mode: "data" | "mongo"): boolean {
return tab !== undefined && isSameDatabase(tab, target, mode) && dataTabCatalog(tab) === (target.catalog || "") && !tab.pinned && !tab.isExecuting && !tab.isCancelling && !tab.isExplaining && !tab.txnSessionId && !tab.pendingDataChangeCount && !tab.hasPendingDataEditorDraft;
}
export function canReuseActiveDataTab(tab: DataTabLike | undefined, target: DataTabTarget): boolean {
return tab !== undefined && isSameDatabase(tab, target) && dataTabCatalog(tab) === (target.catalog || "") && !tab.pinned && !tab.isExecuting && !tab.isCancelling && !tab.isExplaining && !tab.txnSessionId && !tab.pendingDataChangeCount && !tab.hasPendingDataEditorDraft;
return canReuseActiveTableTab(tab, target, "data");
}
export function canReuseActiveMongoTab(tab: DataTabLike | undefined, target: DataTabTarget): boolean {
return canReuseActiveTableTab(tab, target, "mongo");
}
export function canApplyDataTabMetadata(tab: DataTabLike | undefined, target: DataTabTarget, signal?: AbortSignal): boolean {
@@ -73,6 +73,13 @@ export function toMongoCollectionKind(kind?: string | null): MongoCollectionKind
return "collection";
}
/** Drop GridFS buckets and their backing `.files` / `.chunks` collections from object lists. */
export function visibleMongoCollections<T extends { name: string; kind?: string | null; bucketName?: string }>(collections: readonly T[]): T[] {
const bucketNames = new Set(collections.filter((collection) => collection.kind === "bucket" && collection.bucketName).map((collection) => collection.bucketName as string));
const hiddenCollectionNames = new Set([...bucketNames].flatMap((bucketName) => [`${bucketName}.files`, `${bucketName}.chunks`]));
return collections.filter((collection) => collection.kind !== "bucket" && !hiddenCollectionNames.has(collection.name));
}
export function mongoRenameCollectionPreview(database: string, oldName: string, newName: string): string {
return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(oldName)}).renameCollection(${JSON.stringify(newName)})`;
}
@@ -49,7 +49,7 @@ export function isRepeatableNavigationTreeNode(type: TreeNodeType): boolean {
export function shouldActivateTreeNodeOnSingleClick(type: TreeNodeType, activation: SidebarActivation = "single"): boolean {
return activation !== "double" || isDirectNavigationTreeNode(type);
}
const objectBrowserNodeTypes = new Set<TreeNodeType>(["database", "schema", "object-browser"]);
const objectBrowserNodeTypes = new Set<TreeNodeType>(["database", "schema", "object-browser", "mongo-db"]);
/** 开关“单击数据库同时打开数据库项目”命中时,单击数据库类节点直接打开对象浏览器标签页。 */
export function shouldOpenObjectBrowserOnSingleClick(type: TreeNodeType, enabled: boolean): boolean {
@@ -16,6 +16,7 @@ export type ObjectBrowserRowAction = "table-info" | "type-info" | "open-table" |
*/
export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
if (!row) return "none";
if (dbType === "mongodb") return mongoObjectBrowserRowAction(row);
if (row.type === "TABLE") return "table-info";
if (row.type === "EVENT") return "none";
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
@@ -31,6 +32,7 @@ export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, d
*/
export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
if (!row) return "none";
if (dbType === "mongodb") return mongoObjectBrowserRowAction(row);
if (row.type === "TABLE") return "open-table";
if (row.type === "EVENT") return "open-source";
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
@@ -65,10 +67,10 @@ export function resolveRowClickAction(row: ObjectBrowserRow | null | undefined,
* open-table). For rows whose single and double actions are identical
* (e.g. VIEW → open-source both), no deferral is needed.
*/
export function shouldDeferSingleClick(row: ObjectBrowserRow | null | undefined, action: ObjectBrowserRowAction): boolean {
export function shouldDeferSingleClick(row: ObjectBrowserRow | null | undefined, action: ObjectBrowserRowAction, dbType?: DatabaseType): boolean {
if (action === "none") return false;
const single = singleClickRowAction(row);
const double = doubleClickRowAction(row);
const single = singleClickRowAction(row, dbType);
const double = doubleClickRowAction(row, dbType);
return single !== double && action === single;
}
@@ -80,6 +82,10 @@ export function isSourceOnlyObjectBrowserRow(row: ObjectBrowserRow): boolean {
return row.type === "TRIGGER" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY";
}
function mongoObjectBrowserRowAction(row: ObjectBrowserRow): ObjectBrowserRowAction {
return row.type === "TABLE" || row.type === "VIEW" ? "open-table" : "none";
}
function canOpenSource(row: ObjectBrowserRow, dbType?: DatabaseType): boolean {
// Verified PG-family TYPE rows open the read-only details panel instead;
// Xugu keeps its source editor entry through supportsTypeObjectSource below.
@@ -1,5 +1,6 @@
import type { ObjectInfo, TreeNode, TreeNodeType } from "@/types/database";
import type { MongoCollectionKind, ObjectInfo, TreeNode, TreeNodeType } from "@/types/database";
import { pinnedTreeNodeIdentityMatches, type PinnedTreeNodeIdentity } from "@/lib/app/pinnedItems";
import { toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation";
import { buildGroupedObjectTreeNodes, buildSimpleObjectTreeNodes, buildTableTreeNodes, compareDatabaseObjectNames, normalizeDatabaseObjectName } from "@/lib/table/tableTree";
import { parseSlashDelimitedRegexQuery } from "@/lib/common/searchPattern";
@@ -9,6 +10,7 @@ export type ObjectBrowserRow = {
displayName: string;
schema?: string;
type: "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "EVENT" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
collectionKind?: MongoCollectionKind;
valid?: boolean | null;
signature?: string | null;
comment?: string | null;
@@ -189,6 +191,28 @@ export function buildObjectBrowserRows(options: { objects: ObjectInfo[]; databas
return rows;
}
export function buildMongoObjectBrowserRows(options: { collections: Array<{ name: string; kind?: string | null }>; database: string }): ObjectBrowserRow[] {
const seen = new Map<string, number>();
return options.collections.flatMap((collection) => {
const name = collection.name;
if (!name) return [];
const collectionKind = toMongoCollectionKind(collection.kind);
const type: ObjectBrowserRow["type"] = collectionKind === "view" ? "VIEW" : "TABLE";
const baseId = `${options.database}:${name}:${type}:${collectionKind}`;
const index = seen.get(baseId) ?? 0;
seen.set(baseId, index + 1);
return [
{
id: `${baseId}:${index}`,
name,
displayName: name,
type,
collectionKind,
},
];
});
}
function routineSignatureForDisplay(type: ObjectBrowserRow["type"], signature: string | null | undefined): string | undefined {
if (type !== "PROCEDURE" && type !== "FUNCTION") return undefined;
if (signature == null) return undefined;
+2 -4
View File
@@ -134,7 +134,7 @@ import { decorateDatabaseSavedSqlTreeNodes, indexSavedSqlFilesByDatabase, stripD
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
import { mongoCollectionKindFromNode, toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation";
import { mongoCollectionKindFromNode, toMongoCollectionKind, visibleMongoCollections } from "@/lib/sidebar/mongoCollectionMutation";
import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex";
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
@@ -4896,9 +4896,7 @@ export const useConnectionStore = defineStore("connection", () => {
const load = beginTreeNodeLoad(node);
try {
const collections = await api.mongoListCollections(connectionId, database);
const bucketNames = new Set(collections.filter((c) => c.kind === "bucket" && c.bucketName).map((c) => c.bucketName as string));
const hiddenCollectionNames = new Set([...bucketNames].flatMap((bucketName) => [`${bucketName}.files`, `${bucketName}.chunks`]));
const collectionEntries = collections.filter((c) => c.kind !== "bucket").filter((c) => !hiddenCollectionNames.has(c.name));
const collectionEntries = visibleMongoCollections(collections);
const collectionChildren = [...collectionEntries]
.sort((left, right) => compareSidebarNames(left.name, right.name))
.map((col) => ({