From bfd7c7def2bd27076808c6ce89da479e02d7c38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=91=9E=E5=BC=BA?= <353488729@qq.com> Date: Tue, 25 Aug 2026 22:15:40 +0800 Subject: [PATCH] feat(mongo): open object browser from database tree nodes --- .../src/components/layout/ContentArea.vue | 2 +- .../__tests__/ContentArea.outputView.spec.ts | 6 + .../src/components/objects/ObjectBrowser.vue | 122 ++++++++++++------ .../sidebar/SidebarTreeRuntimeHost.vue | 3 + .../useNavigationTargets.store.spec.ts | 60 +++++++++ .../src/composables/useNavigationTargets.ts | 34 ++++- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/it.ts | 4 + apps/desktop/src/i18n/locales/ja.ts | 4 + apps/desktop/src/i18n/locales/ko.ts | 4 + apps/desktop/src/i18n/locales/pt-BR.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + .../database/databaseFeatureSupport.spec.ts | 13 ++ .../__tests__/sidebar/treeNodeClick.spec.ts | 8 ++ .../table/objectBrowserContextWatch.spec.ts | 6 + .../table/objectBrowserRowAction.spec.ts | 8 ++ .../__tests__/table/objectBrowserRows.spec.ts | 31 ++++- .../lib/database/databaseFeatureSupport.ts | 4 +- .../__tests__/mongoCollectionMutation.spec.ts | 15 +++ .../src/lib/sidebar/dataTabOpenPolicy.ts | 14 +- .../lib/sidebar/mongoCollectionMutation.ts | 7 + apps/desktop/src/lib/sidebar/treeNodeClick.ts | 2 +- .../src/lib/table/objectBrowserRowAction.ts | 12 +- .../src/lib/table/objectBrowserRows.ts | 26 +++- apps/desktop/src/stores/connectionStore.ts | 6 +- .../assets/database-drivers.manifest.json | 2 +- plugins/connection-types/mongodb.yaml | 2 +- 29 files changed, 354 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index e9a4ca491f..81246ef4e6 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -2248,7 +2248,7 @@ defineExpose({ diff --git a/apps/desktop/src/components/layout/__tests__/ContentArea.outputView.spec.ts b/apps/desktop/src/components/layout/__tests__/ContentArea.outputView.spec.ts index 2d111bf0e4..06c95d56c4 100644 --- a/apps/desktop/src/components/layout/__tests__/ContentArea.outputView.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/ContentArea.outputView.spec.ts @@ -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}`"'); + }); +}); diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 20b6f30ac4..b1eb50d5bb 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -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(() => 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(() => { 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(() => { - 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[] {
- + @@ -3150,23 +3186,23 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
{{ t("objects.selectedTables", { count: selectedTableCount }) }}
- - - - - @@ -3217,7 +3253,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
-
+
-
+
-
{{ typeLabel(item.type) }}
-
+
{{ typeLabel(item) }}
+
{{ formatObjectBrowserCount(item.estimatedRows) }}
-
+
{{ formatObjectBrowserBytes(item.totalBytes) }}
@@ -3363,11 +3399,11 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
{{ item.displayName }}
- {{ typeLabel(item.type) }} - {{ + {{ typeLabel(item) }} + {{ formatObjectBrowserCount(item.estimatedRows) }} - {{ + {{ formatObjectBrowserBytes(item.totalBytes) }}
diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 1f54cd71e1..d707cca842 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -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 { diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts index 9296ae11db..bfb1d96ffd 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts @@ -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({ diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index c4461f60e8..1843c2bba6 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -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; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 0e876a6971..50686cfe19 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 659d646b75..6fd6e0cc78 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 284eada77a..92184286dd 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 9ea57c0e0f..ba4f85c885 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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}テーブル選択中", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 4bffeba914..0060677b50 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -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}개 선택됨", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 8eaa397fcf..8f8dc021aa 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4bbf620b9d..7022acb58c 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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} 张表", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2f483fc4cb..d7823603f5 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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} 張資料表", diff --git a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts index b342d77f20..d070182b3d 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts @@ -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); }); }); diff --git a/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts index 39c0e11b52..134cde172d 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts @@ -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", () => { diff --git a/apps/desktop/src/lib/__tests__/table/objectBrowserContextWatch.spec.ts b/apps/desktop/src/lib/__tests__/table/objectBrowserContextWatch.spec.ts index df3291575b..fc84e6707f 100644 --- a/apps/desktop/src/lib/__tests__/table/objectBrowserContextWatch.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/objectBrowserContextWatch.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/__tests__/table/objectBrowserRowAction.spec.ts b/apps/desktop/src/lib/__tests__/table/objectBrowserRowAction.spec.ts index fa712e82e0..9aeae87177 100644 --- a/apps/desktop/src/lib/__tests__/table/objectBrowserRowAction.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/objectBrowserRowAction.spec.ts @@ -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); diff --git a/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts b/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts index 47a0f39750..da892df248 100644 --- a/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/database/databaseFeatureSupport.ts b/apps/desktop/src/lib/database/databaseFeatureSupport.ts index 8705defef5..385a84a8d0 100644 --- a/apps/desktop/src/lib/database/databaseFeatureSupport.ts +++ b/apps/desktop/src/lib/database/databaseFeatureSupport.ts @@ -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"; diff --git a/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts b/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts index a0f7a92963..cd501e3f32 100644 --- a/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts +++ b/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts @@ -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); diff --git a/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts b/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts index 0920d01d7d..e17dafd1c1 100644 --- a/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts +++ b/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts @@ -34,8 +34,8 @@ export function dataTabOpenModeFromTreeClick(type: TreeNodeType, event: Omit): boolean { - return tab.mode === "data" && tab.connectionId === target.connectionId && tab.database === target.database; +function isSameDatabase(tab: DataTabLike, target: Pick, 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 { diff --git a/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts b/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts index af358b9d96..271783d60f 100644 --- a/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts +++ b/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts @@ -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(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)})`; } diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts index 2a8f6cd01d..8a7c61a75a 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts @@ -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(["database", "schema", "object-browser"]); +const objectBrowserNodeTypes = new Set(["database", "schema", "object-browser", "mongo-db"]); /** 开关“单击数据库同时打开数据库项目”命中时,单击数据库类节点直接打开对象浏览器标签页。 */ export function shouldOpenObjectBrowserOnSingleClick(type: TreeNodeType, enabled: boolean): boolean { diff --git a/apps/desktop/src/lib/table/objectBrowserRowAction.ts b/apps/desktop/src/lib/table/objectBrowserRowAction.ts index 50021ed90c..08e1e3c1a7 100644 --- a/apps/desktop/src/lib/table/objectBrowserRowAction.ts +++ b/apps/desktop/src/lib/table/objectBrowserRowAction.ts @@ -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. diff --git a/apps/desktop/src/lib/table/objectBrowserRows.ts b/apps/desktop/src/lib/table/objectBrowserRows.ts index 5f773ae3a4..65c0bdc9b8 100644 --- a/apps/desktop/src/lib/table/objectBrowserRows.ts +++ b/apps/desktop/src/lib/table/objectBrowserRows.ts @@ -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(); + 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; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index d2d9e1fc9f..113b2c8560 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -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) => ({ diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index 7bbf9ac1bf..163de7a67f 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -373,7 +373,7 @@ "capabilities": { "queryExecution": true, "metadataBrowse": false, - "objectBrowser": false, + "objectBrowser": true, "objectSource": false, "schemaSearch": false, "diagram": false, diff --git a/plugins/connection-types/mongodb.yaml b/plugins/connection-types/mongodb.yaml index 2d04fd335a..6c9e7c4e56 100644 --- a/plugins/connection-types/mongodb.yaml +++ b/plugins/connection-types/mongodb.yaml @@ -21,7 +21,7 @@ supportLevel: connect capabilities: queryExecution: true metadataBrowse: false - objectBrowser: false + objectBrowser: true objectSource: false schemaSearch: false diagram: false