diff --git a/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts b/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts
index c6ca45fa9c..c672a7365d 100644
--- a/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts
+++ b/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts
@@ -27,10 +27,12 @@ describe("AppTabBar HBase presentation", () => {
});
});
-describe("AppTabBar objects presentation", () => {
- it("uses an amber icon color on regular, pinned, and overflow tab surfaces", () => {
- expect(tabBarSource).toContain('if (tab.mode === "objects") return "text-amber-500 dark:text-amber-400";');
+describe("AppTabBar object browser presentation", () => {
+ it("uses matching icons and colors for object and database browser tabs", () => {
+ expect(tabBarSource).toContain('if (tab.mode === "databases" || tab.mode === "objects") return "text-amber-500 dark:text-amber-400";');
+ expect(tabBarSource).toContain('if (tab.mode === "databases") return Database;');
expect(tabBarSource).toContain('if (tab.mode === "objects") return TableProperties;');
+ expect(tabBarSource.match(/tab\.mode === 'databases'/g)).toHaveLength(2);
expect(tabBarSource.match(/:class="tabIconClass\(tab\)"/g)).toHaveLength(2);
expect(tabBarSource.match(/tabMenuIcon\(tab\).*tabIconClass\(tab\)/g)).toHaveLength(2);
});
diff --git a/apps/desktop/src/components/objects/DatabaseBrowser.vue b/apps/desktop/src/components/objects/DatabaseBrowser.vue
new file mode 100644
index 0000000000..b8b78bdabb
--- /dev/null
+++ b/apps/desktop/src/components/objects/DatabaseBrowser.vue
@@ -0,0 +1,334 @@
+
+
+
+
+
+
+ {{ connection.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("objects.loading") }}
+ {{ t("databaseBrowser.loadFailed", { message: error }) }}
+ {{ t("databaseBrowser.empty") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayValue(row, key) || "-" }}
+
+
+
+
+
+
+
+
{{ row.name }}
+
+ {{ formatObjectBrowserBytes(row.sizeBytes) }}·{{ formatObjectBrowserTimestamp(row.createdAt) }}
+
+
{{ row.comment || "\u00A0" }}
+
+
+
+
+
diff --git a/apps/desktop/src/components/objects/__tests__/DatabaseBrowserFallback.spec.ts b/apps/desktop/src/components/objects/__tests__/DatabaseBrowserFallback.spec.ts
new file mode 100644
index 0000000000..9a9972d198
--- /dev/null
+++ b/apps/desktop/src/components/objects/__tests__/DatabaseBrowserFallback.spec.ts
@@ -0,0 +1,11 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const source = readFileSync(new URL("../DatabaseBrowser.vue", import.meta.url), "utf8");
+
+describe("DatabaseBrowser metadata fallback", () => {
+ it("falls back to basic database names when extended metadata fails", () => {
+ expect(source).toMatch(/await api\.listDatabaseMetadata\(props\.connection\.id\)[\s\S]*?catch \(metadataError\)[\s\S]*?await api\.listDatabases\(props\.connection\.id\)/);
+ expect(source).toMatch(/catch \{\s*throw metadataError;\s*\}/);
+ });
+});
diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
index eab19736b4..cfd1e01156 100644
--- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
+++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
@@ -83,6 +83,7 @@ import {
editableDatabasePropertyGroups,
supportsDatabaseCreation,
supportsDatabaseSearch,
+ supportsConnectionDatabaseBrowser,
supportsConnectionQueryActions,
supportsFieldLineage,
supportsObjectBrowserTreeNode,
@@ -1135,8 +1136,10 @@ function requestDeleteSelectedNode(): boolean {
function onDoubleClick(event: MouseEvent) {
if (dataTabOpenModeFromTreeClick(activeNode.value.type, event, settingsStore.editorSettings.shortcuts.openDataInNewTab) === "new-tab") return;
- const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value, currentDatabaseType());
- if (action === "open-object-browser") {
+ const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value, currentDatabaseType(), canOpenConnectionDatabaseBrowser.value);
+ if (action === "open-database-browser") {
+ void openDatabaseBrowser();
+ } else if (action === "open-object-browser") {
void openObjectBrowser();
} else if (action === "open-object-browser-and-expand") {
void openObjectBrowser();
@@ -1243,6 +1246,19 @@ async function openObjectBrowser() {
}
}
+async function openDatabaseBrowser() {
+ const node = activeNode.value;
+ if (node.type !== "connection" || !node.connectionId) return;
+ try {
+ await connectionStore.ensureConnected(node.connectionId);
+ connectionStore.activeConnectionId = node.connectionId;
+ queryStore.openDatabaseBrowser(node.connectionId);
+ } catch (e: any) {
+ toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
+ openDriverStoreForInstallError(e?.message || String(e));
+ }
+}
+
async function openUserAdmin() {
const node = activeNode.value;
if (!node.connectionId) return;
@@ -3491,6 +3507,10 @@ const canOpenObjectBrowser = computed(() => {
return supportsObjectBrowserTreeNode(rawDatabaseType(), activeNode.value.type);
});
+const canOpenConnectionDatabaseBrowser = computed(() => {
+ return activeNode.value.type === "connection" && supportsConnectionDatabaseBrowser(rawDatabaseType());
+});
+
const canOpenTableImport = computed(() => {
const node = activeNode.value;
const supportedNode = node.type === "table" || ((node.type === "database" || node.type === "schema" || node.type === "group-tables") && canCreateTable.value);
@@ -4106,6 +4126,9 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
if (supportsQueryActions) {
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
}
+ if (canOpenConnectionDatabaseBrowser.value) {
+ items.push({ label: t("contextMenu.openDatabaseBrowser"), action: openDatabaseBrowser, icon: TableProperties });
+ }
if (currentDatabaseType() === "redis") {
items.push({ label: t("contextMenu.instanceInfo"), action: openRedisInstanceInfo, icon: Info });
}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 8ea4c47c45..85b234e681 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -1298,6 +1298,7 @@ export default {
mongo: "Mongo",
gridfs: "GridFS",
vector: "Vector",
+ databases: "Databases",
objects: "Objects",
users: "Users & Privileges",
executionSummary: "Summary",
@@ -1342,6 +1343,13 @@ export default {
fixedTabs: "Fixed tabs",
openDataTabs: "Open tables",
},
+ databaseBrowser: {
+ search: "Search databases...",
+ empty: "No databases found",
+ loadFailed: "Could not load databases: {message}",
+ defaultCharset: "Charset",
+ defaultCollation: "Collation",
+ },
gridfsBrowser: {
bucketCount: "buckets",
fileCount: "files",
@@ -2543,6 +2551,7 @@ export default {
sqlHistory: "Query History",
noSqlHistory: "No related SQL",
openObjectBrowser: "Browse Objects",
+ openDatabaseBrowser: "Browse Databases",
viewData: "View Data",
openInNewDataTab: "View in New Tab",
editStructure: "Edit Structure",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index a1090662a9..a3f8fa6201 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -1275,6 +1275,7 @@ export default withEnglishFallback({
zookeeper: "ZooKeeper",
consul: "Consul",
vector: "Vector",
+ databases: "Bases de datos",
users: "Usuarios y Privilegios",
executionSummary: "Resumen",
messages: "Mensajes",
@@ -2482,6 +2483,7 @@ export default withEnglishFallback({
sqlHistory: "Historial de consultas",
noSqlHistory: "Sin SQL relacionado",
openObjectBrowser: "Explorar objetos",
+ openDatabaseBrowser: "Explorar bases de datos",
viewData: "Ver datos",
openInNewDataTab: "Ver en nueva pestaña",
editStructure: "Editar estructura",
@@ -6285,6 +6287,13 @@ export default withEnglishFallback({
registerInstance: "Registrar instancia",
deregister: "Dar de baja",
},
+ databaseBrowser: {
+ search: "Buscar bases de datos...",
+ empty: "No se encontraron bases de datos",
+ loadFailed: "No se pudieron cargar las bases de datos: {message}",
+ defaultCharset: "Juego de caracteres",
+ defaultCollation: "Cotejamiento",
+ },
gridfsBrowser: {
bucketCount: "Cantidad de buckets",
fileCount: "Archivos",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 721f41ff9d..6fb5d0552a 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -1274,6 +1274,7 @@ export default withEnglishFallback({
consul: "Consul",
mongo: "Mongo",
vector: "Vector",
+ databases: "Database",
objects: "Oggetti",
users: "Utenti e Privilegi",
executionSummary: "Riepilogo",
@@ -2480,6 +2481,7 @@ export default withEnglishFallback({
sqlHistory: "Cronologia Query",
noSqlHistory: "Nessun SQL correlato",
openObjectBrowser: "Esplora Oggetti",
+ openDatabaseBrowser: "Esplora database",
viewData: "Visualizza Dati",
openInNewDataTab: "Visualizza in nuova scheda",
editStructure: "Modifica Struttura",
@@ -6285,6 +6287,13 @@ export default withEnglishFallback({
registerInstance: "Registra istanza",
deregister: "Rimuovi",
},
+ databaseBrowser: {
+ search: "Cerca database...",
+ empty: "Nessun database trovato",
+ loadFailed: "Impossibile caricare i database: {message}",
+ defaultCharset: "Set di caratteri",
+ defaultCollation: "Regole di confronto",
+ },
gridfsBrowser: {
bucketCount: "Bucket",
fileCount: "File",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index e5e0be1184..821ba39ce6 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -1293,6 +1293,7 @@ export default withEnglishFallback({
etcd: "etcd",
mongo: "Mongo",
objects: "オブジェクト",
+ databases: "データベース",
users: "ユーザーと権限",
executionSummary: "サマリー",
messages: "メッセージ",
@@ -2505,6 +2506,7 @@ export default withEnglishFallback({
sqlHistory: "クエリ履歴",
noSqlHistory: "関連するSQLはありません",
openObjectBrowser: "オブジェクトを参照",
+ openDatabaseBrowser: "データベースを参照",
viewData: "データを表示",
openInNewDataTab: "新しいタブで表示",
editStructure: "構造を編集",
@@ -6340,6 +6342,13 @@ export default withEnglishFallback({
registerInstance: "インスタンスを登録",
deregister: "解除",
},
+ databaseBrowser: {
+ search: "データベースを検索...",
+ empty: "データベースが見つかりません",
+ loadFailed: "データベースを読み込めませんでした: {message}",
+ defaultCharset: "文字セット",
+ defaultCollation: "照合順序",
+ },
gridfsBrowser: {
bucketCount: "バケット",
fileCount: "ファイル",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 7d94d4d265..759a2c027e 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -1187,6 +1187,7 @@ export default withEnglishFallback({
mongo: "Mongo",
gridfs: "GridFS",
vector: "Vector",
+ databases: "데이터베이스",
objects: "객체",
users: "사용자 및 권한",
executionSummary: "요약",
@@ -1231,6 +1232,13 @@ export default withEnglishFallback({
fixedTabs: "고정 탭",
openDataTabs: "열린 테이블",
},
+ databaseBrowser: {
+ search: "데이터베이스 검색...",
+ empty: "데이터베이스를 찾을 수 없습니다",
+ loadFailed: "데이터베이스를 불러올 수 없습니다: {message}",
+ defaultCharset: "문자 집합",
+ defaultCollation: "데이터 정렬",
+ },
gridfsBrowser: {
bucketCount: "버킷",
fileCount: "파일",
@@ -2400,6 +2408,7 @@ export default withEnglishFallback({
sqlHistory: "쿼리 기록",
noSqlHistory: "관련 SQL이 없습니다",
openObjectBrowser: "객체 탐색",
+ openDatabaseBrowser: "데이터베이스 탐색",
viewData: "데이터 보기",
openInNewDataTab: "새 탭에서 보기",
editStructure: "구조 편집",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 3315c9e6f2..3dcf5692c1 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -1275,6 +1275,7 @@ export default withEnglishFallback({
consul: "Consul",
mongo: "Mongo",
vector: "Vector",
+ databases: "Bancos de dados",
objects: "Objetos",
users: "Usuários e Privilégios",
executionSummary: "Resumo",
@@ -2482,6 +2483,7 @@ export default withEnglishFallback({
sqlHistory: "Histórico de Consultas",
noSqlHistory: "Nenhum SQL relacionado",
openObjectBrowser: "Explorar Objetos",
+ openDatabaseBrowser: "Explorar bancos de dados",
viewData: "Ver Dados",
openInNewDataTab: "Ver em nova aba",
editStructure: "Editar Estrutura",
@@ -6287,6 +6289,13 @@ export default withEnglishFallback({
registerInstance: "Registrar instância",
deregister: "Remover registro",
},
+ databaseBrowser: {
+ search: "Pesquisar bancos de dados...",
+ empty: "Nenhum banco de dados encontrado",
+ loadFailed: "Não foi possível carregar os bancos de dados: {message}",
+ defaultCharset: "Conjunto de caracteres",
+ defaultCollation: "Agrupamento",
+ },
gridfsBrowser: {
bucketCount: "Buckets",
fileCount: "Arquivos",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index b29804a8cf..ec0746bba7 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -1223,6 +1223,7 @@ export default withEnglishFallback({
mongo: "Mongo",
gridfs: "GridFS",
vector: "向量",
+ databases: "数据库",
objects: "对象",
users: "用户与权限",
executionSummary: "摘要",
@@ -1267,6 +1268,13 @@ export default withEnglishFallback({
fixedTabs: "固定标签页",
openDataTabs: "已打开的数据表",
},
+ databaseBrowser: {
+ search: "搜索数据库...",
+ empty: "没有找到数据库",
+ loadFailed: "加载数据库失败:{message}",
+ defaultCharset: "默认字符集",
+ defaultCollation: "默认排序规则",
+ },
gridfsBrowser: {
bucketCount: "\u5b58\u50a8\u6876",
fileCount: "\u6587\u4ef6",
@@ -2468,6 +2476,7 @@ export default withEnglishFallback({
sqlHistory: "历史查询",
noSqlHistory: "暂无关联 SQL",
openObjectBrowser: "浏览对象",
+ openDatabaseBrowser: "查看资源对象",
viewData: "查看数据",
openInNewDataTab: "新标签页查看",
editStructure: "编辑表结构",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 15178228e8..d48d29e89a 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -1274,6 +1274,7 @@ export default withEnglishFallback({
consul: "Consul",
mongo: "Mongo",
vector: "向量",
+ databases: "資料庫",
objects: "物件",
users: "使用者與權限",
executionSummary: "摘要",
@@ -2481,6 +2482,7 @@ export default withEnglishFallback({
sqlHistory: "歷史查詢",
noSqlHistory: "暫無關聯 SQL",
openObjectBrowser: "瀏覽物件",
+ openDatabaseBrowser: "檢視資源物件",
viewData: "檢視資料",
openInNewDataTab: "新分頁檢視",
editStructure: "編輯資料表結構",
@@ -6280,6 +6282,13 @@ export default withEnglishFallback({
summarySize: "大小",
rootReadonly: "根 znode 不能被修改,請填寫子節點路徑",
},
+ databaseBrowser: {
+ search: "搜尋資料庫...",
+ empty: "找不到資料庫",
+ loadFailed: "載入資料庫失敗:{message}",
+ defaultCharset: "預設字元集",
+ defaultCollation: "預設定序規則",
+ },
gridfsBrowser: {
bucketCount: "儲存桶",
fileCount: "檔案",
diff --git a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts
index dc9ac00071..87fa67b554 100644
--- a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts
+++ b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts
@@ -5,6 +5,7 @@ import { buildGetDatabaseCommentSql } from "@/lib/database/dbAdminSql";
import {
isSchemaAware,
supportsConnectionScopedQueryExecution,
+ supportsConnectionDatabaseBrowser,
supportsDatabaseNameCompletion,
supportsDatabaseSchemaQualifier,
supportsQueryTargetDatabaseListing,
@@ -20,6 +21,13 @@ describe("schema awareness", () => {
});
});
+describe("connection database browser", () => {
+ it("follows object browser support without enabling unsupported connection types", () => {
+ expect(supportsConnectionDatabaseBrowser("postgres")).toBe(true);
+ expect(supportsConnectionDatabaseBrowser("redis")).toBe(false);
+ });
+});
+
describe("connection-scoped query targets", () => {
it("keeps connection-only target types separate from unregistered namespace targets", () => {
expect(usesConnectionOnlyQueryTarget("etcd")).toBe(true);
diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarActiveTabTarget.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarActiveTabTarget.spec.ts
new file mode 100644
index 0000000000..539403462c
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarActiveTabTarget.spec.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from "vitest";
+import { activeTabSidebarTarget, matchesTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
+import type { QueryTab, TreeNode } from "@/types/database";
+
+describe("sidebar active-tab targets", () => {
+ it("selects the connection node for a database-browser tab", () => {
+ const target = activeTabSidebarTarget({ mode: "databases", connectionId: "pg-1" } as QueryTab);
+
+ expect(target).toEqual({ type: "connection", connectionId: "pg-1" });
+ expect(matchesTarget({ id: "connection:pg-1", label: "Postgres", type: "connection", connectionId: "pg-1" } as TreeNode, target!)).toBe(true);
+ expect(matchesTarget({ id: "connection:pg-2", label: "Other", type: "connection", connectionId: "pg-2" } as TreeNode, target!)).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 1f7478960e..e393ec347d 100644
--- a/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts
+++ b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts
@@ -44,6 +44,11 @@ describe("treeNodeClick", () => {
expect(treeNodeRowAction("consul-overview", false, "double")).toBe("toggle");
});
+ it("opens the connection database browser only when the capability is enabled", () => {
+ expect(treeNodeRowDoubleClickAction("connection", false, "single", true, "postgres", true)).toBe("open-database-browser");
+ expect(treeNodeRowDoubleClickAction("connection", false, "double", true, "postgres", false)).toBe("toggle");
+ });
+
it("expands package containers while preserving source behavior for leaf packages", () => {
expect(treeNodeRowAction("package", true)).toBe("toggle");
expect(treeNodeRowAction("package", false)).toBe("open-source");
diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts
index 0543538e1a..c2b402dd2d 100644
--- a/apps/desktop/src/lib/backend/api.ts
+++ b/apps/desktop/src/lib/backend/api.ts
@@ -137,6 +137,7 @@ export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory");
// Schema
export const listDatabases = forward("listDatabases");
+export const listDatabaseMetadata = forward("listDatabaseMetadata");
export const listDatabaseStorage = forward("listDatabaseStorage");
export const getSqlServerCompletionContext = forward("getSqlServerCompletionContext");
export const listDorisCatalogs = forward("listDorisCatalogs");
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index b3f7cdb6a5..083bb07fe1 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -648,6 +648,10 @@ export async function listDatabases(connectionId: string): Promise
{
+ return get(`/api/schema/database-metadata?${qs({ connection_id: connectionId })}`);
+}
+
export async function listDatabaseStorage(connectionId: string, databases: string[]): Promise {
return post("/api/schema/database-storage", {
connection_id: connectionId,
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index 8a7f65e1ea..3c3cc7bcf7 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -928,6 +928,10 @@ export async function listDatabases(connectionId: string): Promise {
+ return invoke("list_database_metadata", { connectionId });
+}
+
export async function listDatabaseStorage(connectionId: string, databases: string[]): Promise {
return invoke("list_database_storage", { connectionId, databases });
}
diff --git a/apps/desktop/src/lib/database/databaseFeatureSupport.ts b/apps/desktop/src/lib/database/databaseFeatureSupport.ts
index 4552c75c34..0d7ba75ef3 100644
--- a/apps/desktop/src/lib/database/databaseFeatureSupport.ts
+++ b/apps/desktop/src/lib/database/databaseFeatureSupport.ts
@@ -167,6 +167,10 @@ export function supportsObjectBrowser(dbType?: DatabaseType): boolean {
return supportsDatabaseFeature(dbType, "objectBrowser");
}
+export function supportsConnectionDatabaseBrowser(dbType?: DatabaseType): boolean {
+ return supportsObjectBrowser(dbType);
+}
+
export function supportsObjectBrowserTreeNode(dbType: DatabaseType | undefined, nodeType: TreeNodeType): boolean {
if (!supportsObjectBrowser(dbType)) return false;
if (nodeType === "database" && usesDatabaseObjectTreeMode(dbType)) return true;
diff --git a/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts b/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts
index 54e8c7bdbe..72ef022893 100644
--- a/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts
+++ b/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts
@@ -2,6 +2,10 @@ import { SIDEBAR_TREE_ROW_HEIGHT, type FlatTreeNode } from "@/composables/useFla
import type { QueryTab, TreeNode } from "@/types/database";
export type ActiveTabSidebarTarget =
+ | {
+ type: "connection";
+ connectionId: string;
+ }
| {
type: "table";
connectionId: string;
@@ -80,6 +84,10 @@ export type ActiveTabSidebarTarget =
export function activeTabSidebarTarget(tab: QueryTab | undefined | null): ActiveTabSidebarTarget | null {
if (!tab) return null;
+ if (tab.mode === "databases") {
+ return { type: "connection", connectionId: tab.connectionId };
+ }
+
if (tab.mode === "data") {
const tableName = tab.tableMeta?.tableName || tab.title;
if (!tableName) return null;
@@ -199,6 +207,10 @@ function schemaMatches(node: TreeNode, schema: string | undefined): boolean {
}
export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): boolean {
+ if (target.type === "connection") {
+ return node.type === "connection" && node.connectionId === target.connectionId;
+ }
+
if (target.type === "mongo-collection") {
if (node.type === "elasticsearch-index") {
return node.connectionId === target.connectionId && node.label === target.collectionName;
diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts
index 5f93de1543..9edcb9155e 100644
--- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts
+++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts
@@ -3,7 +3,7 @@ import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database
import { matchesShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts";
export type TreeNodeRowAction = "open-data" | "open-source" | "open-extension-details" | "toggle" | "none";
-export type TreeNodeRowDoubleClickAction = "open-data" | "activate-data" | "open-object-browser" | "open-object-browser-and-expand" | "open-source" | "open-extension-details" | "open-saved-sql" | "toggle" | "none";
+export type TreeNodeRowDoubleClickAction = "open-data" | "activate-data" | "open-database-browser" | "open-object-browser" | "open-object-browser-and-expand" | "open-source" | "open-extension-details" | "open-saved-sql" | "toggle" | "none";
export type SidebarSelectionCopyAction = "copy-name" | "none";
export type SidebarActivation = "single" | "double";
@@ -120,10 +120,11 @@ export function shouldRunTreeNodeRowAction(action: TreeNodeRowAction, clickDetai
return action !== "none" && clickDetail <= 1;
}
-export function treeNodeRowDoubleClickAction(type: TreeNodeType, canOpenObjectBrowser: boolean, activation: SidebarActivation = "single", canExpand = false, dbType?: DatabaseType): TreeNodeRowDoubleClickAction {
+export function treeNodeRowDoubleClickAction(type: TreeNodeType, canOpenObjectBrowser: boolean, activation: SidebarActivation = "single", canExpand = false, dbType?: DatabaseType, canOpenDatabaseBrowser = false): TreeNodeRowDoubleClickAction {
// Single-click activation already handles the first click in a dblclick
// sequence. Only double-click activation needs a second-stage table action.
if (type === "table") return activation === "double" ? "activate-data" : "none";
+ if (type === "connection" && canOpenDatabaseBrowser) return "open-database-browser";
if (activation === "double") {
if (type === "extension") return "open-extension-details";
if (dataNodeTypes.has(type)) return "open-data";
diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts
index c346b5112c..ed5f39f113 100644
--- a/apps/desktop/src/lib/tabs/tabPresentation.ts
+++ b/apps/desktop/src/lib/tabs/tabPresentation.ts
@@ -130,6 +130,10 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
if (tab.mode === "mqtt") {
return `${connectionDisplayName(tab.connectionId)} - ${t("connection.mqttConsoleTitle")}`;
}
+ if (tab.mode === "databases") {
+ if (compact) return t("tabs.databases");
+ return `${t("tabs.databases")}@${connectionDisplayName(tab.connectionId)}`;
+ }
if (tab.mode === "objects") {
const schema = tab.objectBrowser?.schema;
if (compact) return schema || tab.title;
@@ -436,6 +440,7 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "consul") return t("tabs.consul");
if (tab.mode === "consul-overview") return t("consul.ui.overview");
if (tab.mode === "nacos") return "Nacos";
+ if (tab.mode === "databases") return t("tabs.databases");
if (tab.mode === "objects") return t("tabs.objects");
if (tab.mode === "users") return t("tabs.users");
return tab.mode;
diff --git a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts
index e419570487..c21d77d1f6 100644
--- a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts
+++ b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts
@@ -40,6 +40,20 @@ describe("queryStore database open state", () => {
expect(store.isDatabaseOpen("pg-1", "analytics")).toBe(false);
}, 10_000);
+ it("reuses one database browser tab per connection", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+
+ const firstId = store.openDatabaseBrowser("pg-1");
+ const secondId = store.openDatabaseBrowser("pg-1");
+ const otherConnectionId = store.openDatabaseBrowser("pg-2");
+
+ expect(secondId).toBe(firstId);
+ expect(otherConnectionId).not.toBe(firstId);
+ expect(store.tabs.filter((tab) => tab.mode === "databases")).toHaveLength(2);
+ expect(store.tabs.find((tab) => tab.id === firstId)).toMatchObject({ connectionId: "pg-1", database: "", mode: "databases" });
+ });
+
it("keeps object browser viewport per tab and clears it on schema change", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index 8e63f484a3..4f505462cd 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -1702,6 +1702,29 @@ export const useQueryStore = defineStore("query", () => {
return id;
}
+ function openDatabaseBrowser(connectionId: string) {
+ const existing = tabs.value.find((tab) => tab.mode === "databases" && tab.connectionId === connectionId);
+ if (existing) {
+ switchTab(existing.id);
+ return existing.id;
+ }
+
+ const id = uuid();
+ tabs.value.push({
+ id,
+ title: "Databases",
+ connectionId,
+ database: "",
+ sql: "",
+ isExecuting: false,
+ isCancelling: false,
+ isExplaining: false,
+ mode: "databases",
+ });
+ activeTabId.value = id;
+ return id;
+ }
+
function switchTab(tabId: string) {
activeTabId.value = tabId;
settingsStore.settingsPageActive = false;
@@ -5736,6 +5759,7 @@ export const useQueryStore = defineStore("query", () => {
commitTransaction,
rollbackTransaction,
renameTab,
+ openDatabaseBrowser,
openObjectBrowser,
openMongoGridFs,
openMongoBucket,
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index 70efe936e7..73002b4c2a 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -381,6 +381,12 @@ export interface JdbcPluginStatus {
export interface DatabaseInfo {
name: string;
+ size_bytes?: number | null;
+ created_at?: string | null;
+ updated_at?: string | null;
+ comment?: string | null;
+ default_charset?: string | null;
+ default_collation?: string | null;
}
export interface DatabaseStorageInfo {
@@ -1140,6 +1146,7 @@ export interface QueryTab {
| "mqtt"
| "nacos"
| "nacos-dashboard"
+ | "databases"
| "objects"
| "structure"
| "users"
diff --git a/crates/dbx-core/src/db/clickhouse_driver.rs b/crates/dbx-core/src/db/clickhouse_driver.rs
index c04f518b53..302f63765b 100644
--- a/crates/dbx-core/src/db/clickhouse_driver.rs
+++ b/crates/dbx-core/src/db/clickhouse_driver.rs
@@ -607,7 +607,11 @@ pub async fn test_connection(client: &ChClient, timeout: Duration) -> Result<(),
pub async fn list_databases(client: &ChClient) -> Result, String> {
let result = ch_query(client, "SELECT name FROM system.databases ORDER BY name", None).await?;
- Ok(result.data.iter().map(|row| DatabaseInfo { name: row[0].as_str().unwrap_or("").to_string() }).collect())
+ Ok(result
+ .data
+ .iter()
+ .map(|row| DatabaseInfo { name: row[0].as_str().unwrap_or("").to_string(), ..Default::default() })
+ .collect())
}
pub async fn list_tables(client: &ChClient, database: &str) -> Result, String> {
diff --git a/crates/dbx-core/src/db/cloudflare_d1/mod.rs b/crates/dbx-core/src/db/cloudflare_d1/mod.rs
index 1d7496f84d..7fc925d331 100644
--- a/crates/dbx-core/src/db/cloudflare_d1/mod.rs
+++ b/crates/dbx-core/src/db/cloudflare_d1/mod.rs
@@ -111,7 +111,7 @@ pub async fn connect(config: &ConnectionConfig, timeout: Duration) -> Result Result, String> {
- Ok(vec![DatabaseInfo { name: "main".to_string() }])
+ Ok(vec![DatabaseInfo { name: "main".to_string(), ..Default::default() }])
}
pub async fn list_tables(client: &CloudflareD1Client, _schema: &str) -> Result, String> {
diff --git a/crates/dbx-core/src/db/dolt.rs b/crates/dbx-core/src/db/dolt.rs
index 76a3c96bb1..0f8015f20e 100644
--- a/crates/dbx-core/src/db/dolt.rs
+++ b/crates/dbx-core/src/db/dolt.rs
@@ -79,7 +79,7 @@ fn database_infos(names: Vec) -> Vec {
.into_iter()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
- .map(|name| DatabaseInfo { name })
+ .map(|name| DatabaseInfo { name, ..Default::default() })
.collect()
}
diff --git a/crates/dbx-core/src/db/hbase_driver.rs b/crates/dbx-core/src/db/hbase_driver.rs
index b7361a8e4a..d1ac34b424 100644
--- a/crates/dbx-core/src/db/hbase_driver.rs
+++ b/crates/dbx-core/src/db/hbase_driver.rs
@@ -226,7 +226,7 @@ pub async fn list_namespaces(client: &HBaseClient) -> Result,
let body: RestNamespaces = parse_success_json(response, "list namespaces").await?;
let mut namespaces = body.namespaces;
namespaces.sort_by_key(|name| name.to_ascii_lowercase());
- Ok(namespaces.into_iter().map(|name| DatabaseInfo { name }).collect())
+ Ok(namespaces.into_iter().map(|name| DatabaseInfo { name, ..Default::default() }).collect())
}
pub async fn list_tables(client: &HBaseClient, namespace: &str) -> Result, String> {
diff --git a/crates/dbx-core/src/db/influxdb_driver.rs b/crates/dbx-core/src/db/influxdb_driver.rs
index 18acf7a6d9..faf62fcdea 100644
--- a/crates/dbx-core/src/db/influxdb_driver.rs
+++ b/crates/dbx-core/src/db/influxdb_driver.rs
@@ -347,7 +347,7 @@ pub async fn list_databases(client: &InfluxdbClient) -> Result
return Ok(influx_v2_buckets(client, Duration::from_secs(30))
.await?
.into_iter()
- .map(|bucket| DatabaseInfo { name: bucket.name })
+ .map(|bucket| DatabaseInfo { name: bucket.name, ..Default::default() })
.collect());
}
let result = influx_query(client, "SHOW DATABASES", None).await?;
@@ -356,7 +356,7 @@ pub async fn list_databases(client: &InfluxdbClient) -> Result
.iter()
.flat_map(|r| &r.series)
.flat_map(|s| &s.values)
- .map(|row| DatabaseInfo { name: row[0].as_str().unwrap_or("").to_string() })
+ .map(|row| DatabaseInfo { name: row[0].as_str().unwrap_or("").to_string(), ..Default::default() })
.collect())
}
diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs
index 12e61fa64b..948b8c0611 100644
--- a/crates/dbx-core/src/db/mysql.rs
+++ b/crates/dbx-core/src/db/mysql.rs
@@ -1989,6 +1989,14 @@ pub async fn connect_bare_with_pool_limit_and_setup_database(
const SHOW_DATABASES_SQL: &str = "SHOW DATABASES";
const INFORMATION_SCHEMA_DATABASES_SQL: &str =
"SELECT SCHEMA_NAME FROM information_schema.SCHEMATA ORDER BY SCHEMA_NAME";
+const DATABASE_METADATA_SQL: &str = "SELECT s.SCHEMA_NAME, \
+ s.DEFAULT_CHARACTER_SET_NAME, \
+ s.DEFAULT_COLLATION_NAME, \
+ SUM(COALESCE(t.DATA_LENGTH, 0) + COALESCE(t.INDEX_LENGTH, 0)) AS SIZE_BYTES \
+ FROM information_schema.SCHEMATA s \
+ LEFT JOIN information_schema.TABLES t ON t.TABLE_SCHEMA = s.SCHEMA_NAME \
+ GROUP BY s.SCHEMA_NAME, s.DEFAULT_CHARACTER_SET_NAME, s.DEFAULT_COLLATION_NAME \
+ ORDER BY s.SCHEMA_NAME";
const DATABASE_LIST_QUERY_PLAN: [(&str, bool); 2] =
[(SHOW_DATABASES_SQL, true), (INFORMATION_SCHEMA_DATABASES_SQL, false)];
@@ -2003,6 +2011,40 @@ pub async fn list_databases(pool: &MySqlPool) -> Result, Strin
}
}
+pub async fn list_database_metadata(pool: &MySqlPool) -> Result, String> {
+ let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
+ let result = match conn.query_iter(DATABASE_METADATA_SQL).await {
+ Ok(result) => result,
+ Err(error) => {
+ log::debug!("Falling back to database names after metadata query failed: {error}");
+ drop(conn);
+ return list_databases(pool).await;
+ }
+ };
+ let rows: Vec = match result.collect_and_drop().await {
+ Ok(rows) => rows,
+ Err(error) => {
+ log::debug!("Falling back to database names after metadata query failed: {error}");
+ drop(conn);
+ return list_databases(pool).await;
+ }
+ };
+ Ok(rows
+ .iter()
+ .filter_map(|row| {
+ let name = get_str(row, 0).trim().to_string();
+ (!name.is_empty()).then_some(DatabaseInfo {
+ name,
+ size_bytes: get_opt_i64(row, "SIZE_BYTES"),
+ default_charset: get_opt_str(row, "DEFAULT_CHARACTER_SET_NAME")
+ .filter(|value| !value.trim().is_empty()),
+ default_collation: get_opt_str(row, "DEFAULT_COLLATION_NAME").filter(|value| !value.trim().is_empty()),
+ ..Default::default()
+ })
+ })
+ .collect())
+}
+
async fn list_databases_with_query(
pool: &MySqlPool,
sql: &str,
@@ -2028,12 +2070,12 @@ pub(super) fn database_infos_from_names(
.filter_map(|name| {
saw_row = true;
let name = name.trim().to_string();
- (!name.is_empty()).then_some(DatabaseInfo { name })
+ (!name.is_empty()).then_some(DatabaseInfo { name, ..Default::default() })
})
.collect();
databases.sort_by(|a, b| a.name.cmp(&b.name));
if databases.is_empty() && saw_row && include_catalogless_when_blank {
- return vec![DatabaseInfo { name: String::new() }];
+ return vec![DatabaseInfo { name: String::new(), ..Default::default() }];
}
databases
}
diff --git a/crates/dbx-core/src/db/ob_oracle.rs b/crates/dbx-core/src/db/ob_oracle.rs
index 8425d181e8..79da9aad6b 100644
--- a/crates/dbx-core/src/db/ob_oracle.rs
+++ b/crates/dbx-core/src/db/ob_oracle.rs
@@ -53,7 +53,7 @@ pub async fn list_databases(pool: &mysql_async::Pool) -> Result = result.collect_and_drop().await.map_err(|e| e.to_string())?;
- Ok(rows.iter().map(|row| DatabaseInfo { name: get_str(row, 0) }).collect())
+ Ok(rows.iter().map(|row| DatabaseInfo { name: get_str(row, 0), ..Default::default() }).collect())
}
pub async fn list_schemas(pool: &mysql_async::Pool) -> Result, String> {
diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs
index 1eb870b3c8..42b2f15574 100644
--- a/crates/dbx-core/src/db/postgres.rs
+++ b/crates/dbx-core/src/db/postgres.rs
@@ -2316,11 +2316,31 @@ fn validate_postgres_ssl_paths(url: &str) -> Result<(), String> {
}
fn list_databases_sql() -> &'static str {
- "SELECT datname FROM pg_database \
+ "SELECT datname \
+ FROM pg_database \
WHERE datallowconn = true \
ORDER BY datname"
}
+fn database_metadata_sql() -> &'static str {
+ "SELECT d.datname, \
+ CASE \
+ WHEN has_database_privilege(d.datname, 'CONNECT') \
+ OR COALESCE(( \
+ SELECT pg_has_role(current_user, r.oid, 'MEMBER') \
+ FROM pg_roles r \
+ WHERE r.rolname = 'pg_read_all_stats' \
+ ), false) \
+ THEN pg_database_size(d.oid) \
+ ELSE NULL \
+ END AS size_bytes, \
+ obj_description(d.oid, 'pg_database') AS comment, \
+ pg_encoding_to_char(d.encoding) AS default_charset \
+ FROM pg_database d \
+ WHERE datallowconn = true \
+ ORDER BY d.datname"
+}
+
fn database_storage_sql() -> &'static str {
"SELECT d.datname, \
CASE \
@@ -2343,7 +2363,26 @@ pub async fn list_databases(pool: &Pool) -> Result, String> {
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let rows = postgres_query_cached(&client, list_databases_sql(), &[]).await.map_err(|e| e.to_string())?;
- Ok(rows.iter().map(|row| DatabaseInfo { name: pg_row_try_string(row, 0) }).collect())
+ Ok(rows.iter().map(|row| DatabaseInfo { name: pg_row_try_string(row, 0), ..Default::default() }).collect())
+}
+
+pub async fn list_database_metadata(pool: &Pool) -> Result, String> {
+ let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
+ let rows = postgres_query_cached(&client, database_metadata_sql(), &[]).await.map_err(|e| e.to_string())?;
+ Ok(rows
+ .iter()
+ .map(|row| DatabaseInfo {
+ name: pg_row_try_string(row, 0),
+ size_bytes: row.try_get::<_, Option>(1).ok().flatten(),
+ comment: row.try_get::<_, Option>(2).ok().flatten().filter(|value| !value.trim().is_empty()),
+ default_charset: row
+ .try_get::<_, Option>(3)
+ .ok()
+ .flatten()
+ .filter(|value| !value.trim().is_empty()),
+ ..Default::default()
+ })
+ .collect())
}
pub async fn list_database_storage(pool: &Pool, database_names: &[String]) -> Result, String> {
diff --git a/crates/dbx-core/src/db/rqlite_driver.rs b/crates/dbx-core/src/db/rqlite_driver.rs
index 84e6172bef..d5735953a8 100644
--- a/crates/dbx-core/src/db/rqlite_driver.rs
+++ b/crates/dbx-core/src/db/rqlite_driver.rs
@@ -98,7 +98,7 @@ pub async fn test_connection(client: &RqliteClient, timeout: Duration) -> Result
}
pub async fn list_databases(_client: &RqliteClient) -> Result, String> {
- Ok(vec![DatabaseInfo { name: "main".to_string() }])
+ Ok(vec![DatabaseInfo { name: "main".to_string(), ..Default::default() }])
}
pub async fn list_tables(client: &RqliteClient, _schema: &str) -> Result, String> {
diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs
index 1d98a48c8d..cdb3b5c2f6 100644
--- a/crates/dbx-core/src/db/sqlite.rs
+++ b/crates/dbx-core/src/db/sqlite.rs
@@ -1284,7 +1284,7 @@ pub async fn list_databases(pool: &SqliteHandle) -> Result, St
let mut stmt = conn.prepare("PRAGMA database_list").map_err(|e| e.to_string())?;
let rows = stmt.query_map([], |row| row.get::<_, String>("name")).map_err(|e| e.to_string())?;
rows.filter_map(|row| match row {
- Ok(name) if !name.eq_ignore_ascii_case("temp") => Some(Ok(DatabaseInfo { name })),
+ Ok(name) if !name.eq_ignore_ascii_case("temp") => Some(Ok(DatabaseInfo { name, ..Default::default() })),
Ok(_) => None,
Err(error) => Some(Err(error)),
})
diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs
index e98035bcd4..66aac91dfe 100644
--- a/crates/dbx-core/src/db/sqlserver.rs
+++ b/crates/dbx-core/src/db/sqlserver.rs
@@ -1722,7 +1722,47 @@ pub async fn list_databases(client: &mut SqlServerClient) -> Result(0).unwrap_or("").to_string() }).collect())
+ Ok(rows
+ .iter()
+ .map(|row| DatabaseInfo { name: row.get::<&str, _>(0).unwrap_or("").to_string(), ..Default::default() })
+ .collect())
+}
+
+pub async fn list_database_metadata(client: &mut SqlServerClient) -> Result, String> {
+ let rows = match list_database_metadata_rows(client).await {
+ Ok(rows) => rows,
+ Err(error) => {
+ log::debug!("Falling back to database names after metadata query failed: {error}");
+ return list_databases(client).await;
+ }
+ };
+ Ok(rows
+ .iter()
+ .map(|row| DatabaseInfo {
+ name: row.get::<&str, _>(0).unwrap_or("").to_string(),
+ created_at: row.get::(1).map(|value| value.to_string()),
+ default_collation: row.get::<&str, _>(2).map(str::to_string).filter(|value| !value.trim().is_empty()),
+ size_bytes: row.get::(3),
+ ..Default::default()
+ })
+ .collect())
+}
+
+async fn list_database_metadata_rows(client: &mut SqlServerClient) -> Result, String> {
+ let stream = client
+ .query(
+ "SELECT d.name, d.create_date, d.collation_name, \
+ SUM(COALESCE(CONVERT(bigint, f.size), 0)) * 8192 AS size_bytes \
+ FROM sys.databases d \
+ LEFT JOIN sys.master_files f ON f.database_id = d.database_id \
+ WHERE d.state = 0 \
+ GROUP BY d.name, d.create_date, d.collation_name \
+ ORDER BY d.name",
+ &[],
+ )
+ .await;
+ let stream = stream.map_err(|error| error.to_string())?;
+ stream.into_first_result().await.map_err(|error| error.to_string())
}
pub async fn get_completion_context(client: &mut SqlServerClient) -> Result {
@@ -1776,7 +1816,7 @@ pub async fn list_linked_server_catalogs(
Ok(rows
.iter()
.filter_map(|row| row.get::<&str, _>(0).map(str::trim).filter(|name| !name.is_empty()))
- .map(|name| DatabaseInfo { name: name.to_string() })
+ .map(|name| DatabaseInfo { name: name.to_string(), ..Default::default() })
.collect())
}
diff --git a/crates/dbx-core/src/db/turso_driver.rs b/crates/dbx-core/src/db/turso_driver.rs
index 15e31a5e82..ae383c4de5 100644
--- a/crates/dbx-core/src/db/turso_driver.rs
+++ b/crates/dbx-core/src/db/turso_driver.rs
@@ -140,7 +140,7 @@ pub async fn test_connection(client: &TursoClient, timeout: Duration) -> Result<
}
pub async fn list_databases(_client: &TursoClient) -> Result, String> {
- Ok(vec![DatabaseInfo { name: "main".to_string() }])
+ Ok(vec![DatabaseInfo { name: "main".to_string(), ..Default::default() }])
}
pub async fn list_tables(client: &TursoClient, _schema: &str) -> Result, String> {
diff --git a/crates/dbx-core/src/db/victoriametrics_driver.rs b/crates/dbx-core/src/db/victoriametrics_driver.rs
index 8eaa92ecbb..17c8446ff5 100644
--- a/crates/dbx-core/src/db/victoriametrics_driver.rs
+++ b/crates/dbx-core/src/db/victoriametrics_driver.rs
@@ -183,7 +183,7 @@ pub async fn test_connection(client: &VictoriaMetricsClient, timeout: Duration)
}
pub async fn list_databases(client: &VictoriaMetricsClient) -> Result, String> {
- Ok(vec![DatabaseInfo { name: client.database_label.clone() }])
+ Ok(vec![DatabaseInfo { name: client.database_label.clone(), ..Default::default() }])
}
pub async fn list_tables(client: &VictoriaMetricsClient) -> Result, String> {
diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs
index cf02fef3a8..4e00a26c45 100644
--- a/crates/dbx-core/src/schema.rs
+++ b/crates/dbx-core/src/schema.rs
@@ -174,6 +174,16 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul
retry_metadata_connection(state, connection_id, None, || list_databases_once(state, connection_id)).await
}
+/// Loads the more expensive database-level properties needed only by the
+/// connection resource browser. General metadata paths keep using
+/// `list_databases_core`, which only enumerates names.
+pub async fn list_database_metadata_core(
+ state: &AppState,
+ connection_id: &str,
+) -> Result, String> {
+ retry_metadata_connection(state, connection_id, None, || list_database_metadata_once(state, connection_id)).await
+}
+
pub async fn list_database_storage_core(
state: &AppState,
connection_id: &str,
@@ -592,7 +602,7 @@ async fn list_databases_once(state: &AppState, connection_id: &str) -> Result Result Result, String> {
+ let config = connection_config(state, connection_id).await;
+ if config.as_ref().is_some_and(|config| db::dolt::is_config(config) || is_doris_family_config(config)) {
+ return list_databases_once(state, connection_id).await;
+ }
+ let connections = state.connections.read().await;
+ if let Some(client) = extract_pool!(&connections, connection_id, SqlServer) {
+ drop(connections);
+ let mut client = client.lock().await;
+ return db::sqlserver::list_database_metadata(&mut client).await;
+ }
+ if let Some(PoolKind::Mysql(pool, mode)) = connections.get(connection_id) {
+ let pool = pool.clone();
+ let mode = *mode;
+ drop(connections);
+ return if mode == MysqlMode::OceanBaseOracle {
+ db::ob_oracle::list_databases(&pool).await
+ } else {
+ db::mysql::list_database_metadata(&pool).await
+ };
+ }
+ if let Some(pool) = extract_pool!(&connections, connection_id, Postgres) {
+ drop(connections);
+ return db::postgres::list_database_metadata(&pool).await;
+ }
+ drop(connections);
+ list_databases_once(state, connection_id).await
+}
+
pub async fn list_schemas_core(state: &AppState, connection_id: &str, database: &str) -> Result, String> {
list_schemas_core_with_visible_filter(state, connection_id, database, false).await
}
@@ -3347,7 +3386,7 @@ for line in sys.stdin:
}
fn test_database_info(name: &str) -> super::db::DatabaseInfo {
- super::db::DatabaseInfo { name: name.to_string() }
+ super::db::DatabaseInfo { name: name.to_string(), ..Default::default() }
}
#[test]
diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs
index f3b5385752..3dd440826c 100644
--- a/crates/dbx-core/src/types.rs
+++ b/crates/dbx-core/src/types.rs
@@ -1,8 +1,20 @@
use serde::{Deserialize, Serialize};
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DatabaseInfo {
pub name: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub size_bytes: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub created_at: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub updated_at: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub comment: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub default_charset: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub default_collation: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs
index e98aeacd81..18383314ce 100644
--- a/crates/dbx-web/src/main.rs
+++ b/crates/dbx-web/src/main.rs
@@ -390,6 +390,7 @@ async fn main() {
.route("/agents/progress/{operationId}", get(routes::agents::agent_progress))
// Schema
.route("/schema/databases", get(routes::schema::list_databases))
+ .route("/schema/database-metadata", get(routes::schema::list_database_metadata))
.route("/schema/database-storage", post(routes::schema::list_database_storage))
.route("/schema/sqlserver/completion-context", get(routes::schema::get_sqlserver_completion_context))
.route("/schema/doris/catalogs", get(routes::schema::list_doris_catalogs))
diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs
index d24ef4ba52..44db8f254f 100644
--- a/crates/dbx-web/src/routes/schema.rs
+++ b/crates/dbx-web/src/routes/schema.rs
@@ -42,6 +42,15 @@ pub async fn list_databases(
Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?))
}
+pub async fn list_database_metadata(
+ State(state): State>,
+ Query(q): Query,
+) -> Result, AppError> {
+ let result =
+ dbx_core::schema::list_database_metadata_core(&state.app, &q.connection_id).await.map_err(AppError::from)?;
+ Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?))
+}
+
pub async fn list_database_storage(
State(state): State>,
Json(request): Json,
diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs
index 3ff901a593..f62caaf762 100644
--- a/src-tauri/src/commands/schema.rs
+++ b/src-tauri/src/commands/schema.rs
@@ -19,6 +19,14 @@ pub async fn list_databases(
dbx_core::schema::list_databases_core(&state, &connection_id).await
}
+#[tauri::command]
+pub async fn list_database_metadata(
+ state: State<'_, Arc>,
+ connection_id: String,
+) -> Result, String> {
+ dbx_core::schema::list_database_metadata_core(&state, &connection_id).await
+}
+
#[tauri::command]
pub async fn list_database_storage(
state: State<'_, Arc>,
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 323b8fcb54..81dc1c201c 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1629,6 +1629,7 @@ pub fn run() {
commands::plugins::install_jdbc_plugin_local,
commands::plugins::uninstall_jdbc_plugin,
commands::schema::list_databases,
+ commands::schema::list_database_metadata,
commands::schema::list_database_storage,
commands::schema::get_sqlserver_completion_context,
commands::schema::list_doris_catalogs,