feat(desktop): add connection-level database browser

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-12 02:20:05 +08:00
1 parent 44dbd0e0e7
commit fee3ed1d5a
46 files changed
+781 -29

No files matched your search

@@ -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);
@@ -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);
});
});
@@ -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");
+1
View File
@@ -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");
+4
View File
@@ -648,6 +648,10 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
return get(`/api/schema/databases?${qs({ connection_id: connectionId })}`);
}
export async function listDatabaseMetadata(connectionId: string): Promise<DatabaseInfo[]> {
return get(`/api/schema/database-metadata?${qs({ connection_id: connectionId })}`);
}
export async function listDatabaseStorage(connectionId: string, databases: string[]): Promise<DatabaseStorageInfo[]> {
return post("/api/schema/database-storage", {
connection_id: connectionId,
+4
View File
@@ -928,6 +928,10 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
return invoke("list_databases", { connectionId });
}
export async function listDatabaseMetadata(connectionId: string): Promise<DatabaseInfo[]> {
return invoke("list_database_metadata", { connectionId });
}
export async function listDatabaseStorage(connectionId: string, databases: string[]): Promise<DatabaseStorageInfo[]> {
return invoke("list_database_storage", { connectionId, databases });
}
@@ -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;
@@ -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;
@@ -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";
@@ -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;