{{ 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