fix(sidebar): locate database and workspace tabs in navigation

Closes #11253
This commit is contained in:
二丫讲梵
2026-10-09 09:57:51 +08:00
committed by GitHub
parent 607d3a9070
commit e63faca4cd
19 changed files with 283 additions and 25 deletions
@@ -125,7 +125,7 @@ describe("Object browser locate event through ContentArea and the workspace", ()
isExecuting: false,
tableMeta: { ...mocks.target, columns: [], primaryKeys: [] },
});
expect(activeTabSidebarTarget(onLocateTab.mock.calls[0]![0])).toEqual({ type: "table", connectionId: "browser-connection", database: "browser_database", schema: "row_schema", tableName: "orders.with.dots" });
expect(activeTabSidebarTarget(onLocateTab.mock.calls[0]![0])).toEqual({ type: "table", connectionId: "browser-connection", database: "browser_database", catalog: "external_catalog", schema: "row_schema", tableName: "orders.with.dots" });
expect(onOpenObjectTable).not.toHaveBeenCalled();
expect(queryStore.tabs).toEqual([tab]);
expect(queryStore.activeTabId).toBe(tab.id);
@@ -43,6 +43,7 @@ import { isInternalDorisCatalog, usesTreeSchemaMode } from "@/lib/database/datab
import { connectionObjectTreeNodeSchema, connectionShouldDiscoverJdbcSchemas, connectionUsesConnectionRootSchemaMode, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import {
activeTabSidebarTarget,
expandSidebarLocatePath,
findSidebarConnectionNode,
findSidebarNodeForActiveTab,
findSidebarNodeForTarget,
@@ -1600,6 +1601,14 @@ async function locateActiveTabInSidebar() {
}
async function locateTabInSidebar(tab: QueryTab | undefined | null, align: SidebarNodeScrollAlign = "center") {
try {
await locateTabInSidebarTarget(tab, align);
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 5000);
}
}
async function locateTabInSidebarTarget(tab: QueryTab | undefined | null, align: SidebarNodeScrollAlign) {
if (!tab) return;
const tabTarget = activeTabSidebarTarget(tab);
@@ -1623,8 +1632,12 @@ async function locateTabInSidebar(tab: QueryTab | undefined | null, align: Sideb
const tabTableCandidate = locatesSavedSql || cursorCandidate ? null : tableLocateCandidateFromTarget(tabTarget, config);
const locateTableCandidate = cursorCandidate ?? tabTableCandidate;
const fallbackTarget = locatesSavedSql ? tabTarget : (queryContextTargetFromCandidate(tab, cursorCandidate) ?? tabTarget);
const initialTarget = locateTableCandidate ? tableTargetFromCandidate(locateTableCandidate) : fallbackTarget;
let initialTarget = locateTableCandidate ? tableTargetFromCandidate(locateTableCandidate) : fallbackTarget;
if (!initialTarget) return;
if (initialTarget.type === "query-context" && connectionUsesConnectionRootSchemaMode(config)) {
const schema = initialTarget.schema || initialTarget.database;
initialTarget = { ...initialTarget, database: schema, schema };
}
// Ensure the tree is loaded deep enough to contain the preferred target.
// Saved SQL rows live below their database's runtime Queries node. Loading
@@ -1638,7 +1651,7 @@ async function locateTabInSidebar(tab: QueryTab | undefined | null, align: Sideb
database: savedSqlFile.database,
}
: initialTarget;
await ensureTreeLoadedForTarget(treeLoadTarget);
await ensureTreeLoadedForTarget(treeLoadTarget, { requireDatabaseChildren: locatesSavedSql });
// Clear any active search filter so the node is visible
if (isRootListPartial.value) {
@@ -1672,18 +1685,13 @@ async function locateTabInSidebar(tab: QueryTab | undefined | null, align: Sideb
nodePath = findNodePathForTarget(fallbackTarget, store.treeNodes);
}
if (!nodePath) return;
for (const ancestor of nodePath) {
// Only flip the arrow when this node's own children are already loaded
// (e.g. by ensureTreeLoadedForTarget above). Forcing isExpanded on a
// table/collection whose column/index groups were never fetched shows an
// "expanded" arrow with no content underneath (issue #5850).
if (!ancestor.isExpanded && store.canUseLoadedTreeNodeToggle(ancestor)) {
ancestor.isExpanded = true;
}
if (!nodePath) {
toast(t("sidebar.locateTargetNotFound"), 5000);
return;
}
expandSidebarLocatePath(nodePath, store.canUseLoadedTreeNodeToggle);
// 表分组行同样是投影出的合成节点(不登记已加载子节点,上面的守卫会跳过),
// 折叠状态存在布局里,必须经布局 op 展开,否则下次投影又把它折叠回去。
for (const node of nodePath) {
@@ -1721,17 +1729,20 @@ function tableTargetFromCandidate(candidate: QueryCursorTableCandidate): ActiveT
type: "table",
connectionId: candidate.connectionId,
database: candidate.database,
catalog: candidate.catalog,
schema: candidate.schema,
tableName: candidate.tableName,
};
}
function tableLocateCandidateFromTarget(target: ActiveTabSidebarTarget | null, config: ReturnType<typeof store.getConfig>): QueryCursorTableCandidate | null {
if (target?.type === "hbase-table") return { connectionId: target.connectionId, database: target.namespace, tableName: target.tableName };
if (target?.type !== "table") return null;
const database = connectionUsesConnectionRootSchemaMode(config) && target.schema ? target.schema : target.database;
return {
connectionId: target.connectionId,
database,
catalog: target.catalog,
schema: target.schema,
tableName: target.tableName,
};
@@ -1742,7 +1753,7 @@ function resolveLoadedLocateTarget(target: ActiveTabSidebarTarget, candidate: Qu
return findLoadedTableTargetForCandidate(store.treeNodes, candidate);
}
async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: { force?: boolean }) {
async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: { force?: boolean; requireDatabaseChildren?: boolean }) {
if (target.type === "saved-sql-file" || target.type === "etcd-root" || target.type === "etcd-dashboard" || target.type === "etcd-access-control" || target.type === "zookeeper-root" || target.type === "consul-root") return;
const connId = target.connectionId;
if (!connId) return;
@@ -1757,20 +1768,27 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
const force = opts?.force ?? false;
const loadOptions = force ? { force: true } : undefined;
if (target.type === "connection") return;
// Ensure databases are loaded under the connection
const connNode = findSidebarConnectionNode(store.treeNodes, connId);
if (connNode && (force || !connNode.children || connNode.children.length === 0)) {
try {
if (config.db_type === "redis") {
await store.loadRedisDatabases(connId);
await store.loadRedisDatabases(connId, { showAll: true });
} else if (config.db_type === "mongodb") {
await store.loadMongoDatabases(connId);
} else if (config.db_type === "dynamodb") {
await store.loadDynamoDbTables(connId);
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch" || config.db_type === "solr" || config.db_type === "couchdb") {
await store.loadElasticsearchIndices(connId);
} else if (config.db_type === "qdrant" || config.db_type === "milvus" || config.db_type === "weaviate" || config.db_type === "chromadb") {
} else if (config.db_type === "milvus") {
// Milvus collections belong below a database, even during a forced locate retry.
await store.loadMilvusDatabases(connId);
} else if (config.db_type === "qdrant" || config.db_type === "weaviate" || config.db_type === "chromadb") {
await store.loadVectorCollections(connId);
} else if (config.db_type === "mqtt") {
await store.loadMqttTopics(connId);
} else if (config.db_type === "mq") {
await store.loadMqTenants(connId, loadOptions);
} else if (config.db_type === "nacos") {
@@ -1786,7 +1804,22 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
if (config.db_type === "mq" || config.db_type === "nacos" || config.db_type === "consul") return;
if (!("database" in target) || !target.database) return;
const usesExactCatalogScope = target.type === "query-context";
if (target.type === "redis-db") {
if (!findNodePathForTarget(target, store.treeNodes)) await store.loadRedisDatabases(connId, { showAll: true });
return;
}
if (target.type === "mongo-collection" || target.type === "mongo-gridfs") {
if (force || !findNodePathForTarget(target, store.treeNodes)) await store.loadMongoCollections(connId, target.database);
return;
}
if (target.type === "vector-collection") {
if (force || !findNodePathForTarget(target, store.treeNodes)) await store.loadVectorCollections(connId, target.database);
return;
}
// Locating a database only needs its ancestors, not its entire object list.
if (!opts?.requireDatabaseChildren && target.type === "query-context" && !target.schema && findNodePathForTarget(target, store.treeNodes)) return;
const usesExactCatalogScope = target.type === "query-context" || target.type === "table";
const targetCatalog = usesExactCatalogScope ? target.catalog : undefined;
if (usesExactCatalogScope) {
const catalogNode = findDorisCatalogNode(store.treeNodes, connId, targetCatalog);
@@ -1802,11 +1835,12 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
// Find the database node
const targetSchema = "schema" in target ? target.schema : undefined;
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
if (target.type === "table" && connectionUsesConnectionRootSchemaMode(config)) {
if ((target.type === "table" || target.type === "query-context") && connectionUsesConnectionRootSchemaMode(config)) {
const schemaName = targetSchema || target.database;
if (!schemaName) return;
const schemaNode = findSchemaNode(store.treeNodes, connId, schemaName, schemaName);
if (!schemaNode) return;
if (target.type === "query-context") return;
if (force || !schemaNode.children || schemaNode.children.length === 0) {
await store.loadTables(connId, schemaNode.database || schemaName, schemaNode.schema ?? schemaName, loadOptions);
}
@@ -1816,7 +1850,14 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
const dbNode = findDatabaseNode(store.treeNodes, connId, target.database, targetCatalog, usesExactCatalogScope);
if (!dbNode) return;
const databaseChildrenLoaded = !!dbNode.children && dbNode.children.length > 0;
if (targetCatalog) {
if (!opts?.requireDatabaseChildren && target.type === "query-context" && !target.schema) return;
if (force || (opts?.requireDatabaseChildren ? !store.canUseLoadedTreeNodeToggle(dbNode) : !dbNode.children?.length)) await store.loadDorisCatalogTables(dbNode, loadOptions);
if (target.type === "table") await ensureTableObjectGroupsLoaded(target, loadOptions);
return;
}
// Runtime Queries children do not mean that the database metadata was loaded.
const databaseChildrenLoaded = opts?.requireDatabaseChildren ? store.canUseLoadedTreeNodeToggle(dbNode) : !!dbNode.children && dbNode.children.length > 0;
const usesSchemaTree = (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) || connectionShouldDiscoverJdbcSchemas(config);
const shouldLoadSchemaTables = target.type === "table" && !!targetSchema && usesSchemaTree;
if (!force && databaseChildrenLoaded && !shouldLoadSchemaTables) return;
@@ -1871,6 +1912,7 @@ function findTableObjectGroupNodes(nodes: TreeNode[], target: Extract<ActiveTabS
(node.type === "group-tables" || node.type === "group-dolt-system-tables" || node.type === "group-views" || node.type === "group-materialized-views") &&
node.connectionId === target.connectionId &&
sameTreeName(node.database, target.database) &&
(node.catalog || undefined) === (target.catalog || undefined) &&
(!target.schema || sameTreeName(node.schema, target.schema))
) {
matches.push(node);
+1
View File
@@ -467,6 +467,7 @@ export default withEnglishFallback({
regexSearch: "Müntəzəm ifadə ilə axtarış",
regexSearchTooltip: "Yan paneldəki adları, şərhləri və ləqəbləri JavaScript müntəzəm ifadələri ilə uyğunlaşdırın.",
clearFilter: "Süzgəci təmizlə",
locateTargetNotFound: "Element yan paneldə mövcud deyil. O, silinmiş və ya gizlədilmiş ola bilər.",
locateActiveTab: "Yan paneldə tap",
},
savedSql: {
+1
View File
@@ -750,6 +750,7 @@ export default {
regexSearch: "Regex search",
regexSearchTooltip: "Match sidebar names, comments, and aliases with JavaScript regular expressions.",
clearFilter: "Clear filter",
locateTargetNotFound: "The item is not available in the sidebar. It may have been removed or hidden.",
locateActiveTab: "Locate in sidebar",
},
savedSql: {
+1
View File
@@ -744,6 +744,7 @@ export default withEnglishFallback({
regexSearch: "Búsqueda con regex",
regexSearchTooltip: "Coincide con nombres, comentarios y alias mediante expresiones regulares de JavaScript.",
clearFilter: "Limpiar filtro",
locateTargetNotFound: "El elemento no está disponible en la barra lateral. Puede que se haya eliminado u ocultado.",
locateActiveTab: "Localizar en la barra lateral",
},
savedSql: {
+1
View File
@@ -644,6 +644,7 @@ export default withEnglishFallback({
regexSearch: "Pencarian regex",
regexSearchTooltip: "Cocokkan nama, komentar, dan alias di bilah sisi dengan ekspresi reguler JavaScript.",
clearFilter: "Bersihkan filter",
locateTargetNotFound: "Item tidak tersedia di bilah sisi. Item mungkin telah dihapus atau disembunyikan.",
locateActiveTab: "Cari di bilah sisi",
},
savedSql: {
+1
View File
@@ -733,6 +733,7 @@ export default withEnglishFallback({
regexSearch: "Ricerca regex",
regexSearchTooltip: "Trova nomi, commenti e alias con espressioni regolari JavaScript.",
clearFilter: "Cancella filtro",
locateTargetNotFound: "L’elemento non è disponibile nella barra laterale. Potrebbe essere stato rimosso o nascosto.",
locateActiveTab: "Trova nella barra laterale",
},
savedSql: {
+1
View File
@@ -734,6 +734,7 @@ export default withEnglishFallback({
regexSearch: "正規表現検索",
regexSearchTooltip: "JavaScript 正規表現でサイドバーの名前、コメント、エイリアスを照合します。",
clearFilter: "フィルターをクリア",
locateTargetNotFound: "サイドバーに該当する項目が見つかりません。削除または非表示にされている可能性があります。",
locateActiveTab: "サイドバーで表示",
},
savedSql: {
+1
View File
@@ -736,6 +736,7 @@ export default withEnglishFallback({
regexSearch: "정규식 검색",
regexSearchTooltip: "JavaScript 정규식으로 사이드바 이름, 주석, 별칭을 검색합니다.",
clearFilter: "필터 지우기",
locateTargetNotFound: "사이드바에서 해당 항목을 찾을 수 없습니다. 삭제되었거나 숨겨져 있을 수 있습니다.",
locateActiveTab: "사이드바에서 찾기",
},
savedSql: {
+1
View File
@@ -734,6 +734,7 @@ export default withEnglishFallback({
regexSearch: "Busca por regex",
regexSearchTooltip: "Correspondência de nomes, comentários e aliases com expressões regulares JavaScript.",
clearFilter: "Limpar filtro",
locateTargetNotFound: "O item não está disponível na barra lateral. Ele pode ter sido removido ou ocultado.",
locateActiveTab: "Localizar na barra lateral",
},
savedSql: {
+1
View File
@@ -716,6 +716,7 @@ export default withEnglishFallback({
regexSearch: "Поиск по регулярному выражению",
regexSearchTooltip: "Сопоставлять имена, комментарии и псевдонимы в боковой панели с помощью регулярных выражений JavaScript.",
clearFilter: "Очистить фильтр",
locateTargetNotFound: "Элемент недоступен на боковой панели. Возможно, он был удалён или скрыт.",
locateActiveTab: "Найти в боковой панели",
},
savedSql: {
+1
View File
@@ -467,6 +467,7 @@ export default withEnglishFallback({
regexSearch: "Regex araması",
regexSearchTooltip: "Kenar çubuğu adlarını, açıklamaları ve takma adları JavaScript düzenli ifadeleriyle eşleştirin.",
clearFilter: "Filtreyi temizle",
locateTargetNotFound: "Öğe kenar çubuğunda bulunamıyor. Silinmiş veya gizlenmiş olabilir.",
locateActiveTab: "Kenar çubuğunda bul",
},
savedSql: {
+1
View File
@@ -687,6 +687,7 @@ export default withEnglishFallback({
regexSearch: "正则搜索",
regexSearchTooltip: "使用 JavaScript 正则表达式匹配侧边栏名称、备注和别名。",
clearFilter: "清除筛选",
locateTargetNotFound: "左侧导航中未找到对应项目,该项目可能已被删除或隐藏。",
locateActiveTab: "在侧边栏中定位",
},
savedSql: {
+1
View File
@@ -739,6 +739,7 @@ export default withEnglishFallback({
regexSearch: "正規表示式搜尋",
regexSearchTooltip: "使用 JavaScript 正規表示式比對側邊欄名稱、備註與別名。",
clearFilter: "清除篩選",
locateTargetNotFound: "側邊欄中找不到對應項目,該項目可能已被刪除或隱藏。",
locateActiveTab: "在側邊欄中定位",
},
savedSql: {
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { buildDatabaseSavedSqlRootNode, decorateDatabaseSavedSqlTreeNodes, indexSavedSqlFilesByDatabase, savedSqlFilesForDatabase, stripDatabaseSavedSqlTreeNodes, withDatabaseSavedSqlRoot } from "@/lib/savedSql/savedSqlDatabaseTree";
import { expandSidebarLocatePath, findNodePathForTarget, findSidebarNodeForTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import { flattenTree } from "@/composables/useFlatTree";
import type { SavedSqlFile, TreeNode } from "@/types/database";
function file(input: Partial<SavedSqlFile> & Pick<SavedSqlFile, "id" | "name" | "connectionId" | "database">): SavedSqlFile {
@@ -121,3 +123,32 @@ describe("database saved SQL tree", () => {
expect(hiveRoot).toMatchObject({ catalog: "hive", children: [{ catalog: "hive", savedSqlId: "hive" }] });
});
});
describe("saved SQL locate expansion", () => {
it.each([undefined, "external"])("reveals a saved file through a collapsed runtime Queries node (catalog: %s)", (catalog) => {
const saved = file({ id: "saved", name: "report.sql", connectionId: "conn", database: "app", catalog });
const database: TreeNode = { id: "db", type: "database", label: "app", connectionId: "conn", database: "app", catalog, isExpanded: false };
const queries = buildDatabaseSavedSqlRootNode(database, [saved])!;
database.children = [queries];
const connection: TreeNode = { id: "conn", type: "connection", label: "Connection", connectionId: "conn", isExpanded: false, children: [database] };
const tree = [connection];
const target = { type: "saved-sql-file" as const, savedSqlId: saved.id };
const path = findNodePathForTarget(target, tree)!;
expect(findSidebarNodeForTarget(target, flattenTree(tree))).toBeNull();
// Database/connection metadata has loaded; Queries is synthesized locally
// and intentionally has no metadata load marker.
const loaded = new Set([connection.id, database.id]);
expandSidebarLocatePath(path, (node) => loaded.has(node.id));
expect(findSidebarNodeForTarget(target, flattenTree(tree))?.node.savedSqlId).toBe(saved.id);
expect(queries.isExpanded).toBe(true);
expect(path[path.length - 1]!.isExpanded).not.toBe(true);
});
it("keeps unloaded object leaves collapsed when revealing their parent path", () => {
const table: TreeNode = { id: "table", type: "table", label: "orders", isExpanded: false };
const database: TreeNode = { id: "db", type: "database", label: "app", isExpanded: false, children: [table] };
expandSidebarLocatePath([database, table], (node) => node.id === database.id);
expect(flattenTree([database]).map((row) => row.id)).toEqual(["db", "table"]);
expect(table.isExpanded).toBe(false);
});
});
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { activeTabSidebarTarget, matchesTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import { findLoadedTableTargetForCandidate, queryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget";
import type { QueryTab, TreeNode } from "@/types/database";
describe("sidebar active-tab targets", () => {
@@ -51,3 +52,67 @@ describe("sidebar active-tab targets", () => {
expect(matchesTarget({ id: "dm-1:SERVICE_DB", label: "SERVICE_DB", type: "database", connectionId: "dm-1", database: "SERVICE_DB" } as TreeNode, target!)).toBe(false);
});
});
describe("locating workspace tabs", () => {
it("uses the object browser namespace instead of the tab display title", () => {
const target = activeTabSidebarTarget({ mode: "objects", connectionId: "conn", database: "db", title: "renamed", objectBrowser: { schema: "public", catalog: "external" } } as QueryTab);
expect(target).toEqual({ type: "query-context", connectionId: "conn", database: "db", schema: "public", catalog: "external" });
expect(matchesTarget({ type: "schema", connectionId: "conn", database: "db", schema: "public", label: "public", catalog: "external" } as TreeNode, target!)).toBe(true);
});
it.each(["database", "mongo-db", "vector-database"] as const)("locates an object browser in a %s tree", (type) => {
const target = activeTabSidebarTarget({ mode: "objects", connectionId: "conn", database: "db" } as QueryTab);
expect(matchesTarget({ type, connectionId: "conn", database: "db", label: "db" } as TreeNode, target!)).toBe(true);
});
it("locates Redis by database number, independent of its alias and key count", () => {
const target = activeTabSidebarTarget({ mode: "redis", connectionId: "conn", database: "0" } as QueryTab);
expect(matchesTarget({ type: "redis-db", connectionId: "conn", database: "0", label: "Cache (123)" } as TreeNode, target!)).toBe(true);
expect(matchesTarget({ type: "redis-db", connectionId: "conn", database: "1", label: "Cache (123)" } as TreeNode, target!)).toBe(false);
});
it("locates structure tabs by their object identity and catalog", () => {
const target = activeTabSidebarTarget({ mode: "structure", connectionId: "conn", database: "db", catalog: "external", structureTableName: "orders", title: "renamed" } as QueryTab);
const node = { type: "table", connectionId: "conn", database: "db", catalog: "external", label: "orders" } as TreeNode;
expect(matchesTarget(node, target!)).toBe(true);
expect(matchesTarget({ ...node, catalog: "other" }, target!)).toBe(false);
});
it.each(["mq", "redis-dashboard", "nacos-dashboard", "plugin-workbench"] as const)("locates the connection for %s without an object target", (mode) => {
expect(activeTabSidebarTarget({ mode, connectionId: "conn" } as QueryTab)).toEqual({ type: "connection", connectionId: "conn" });
expect(activeTabSidebarTarget({ mode, connectionId: "" } as QueryTab)).toBeNull();
});
});
describe("table locate candidates", () => {
it("keeps catalog scope from the query through loaded-node resolution", () => {
const candidate = queryCursorTableCandidate({ mode: "query", connectionId: "conn", catalog: "hive", database: "db", sql: "SELECT * FROM orders" } as QueryTab, "doris");
expect(candidate?.catalog).toBe("hive");
const wrong = { type: "table", connectionId: "conn", database: "db", catalog: "iceberg", label: "orders" } as TreeNode;
expect(findLoadedTableTargetForCandidate([wrong], candidate!)).toBeNull();
expect(findLoadedTableTargetForCandidate([wrong, { ...wrong, catalog: "hive" }], candidate!)?.type).toBe("table");
});
it("accepts the database as the effective schema for schema-less table nodes", () => {
const target = findLoadedTableTargetForCandidate([{ type: "table", connectionId: "conn", database: "db", label: "orders" } as TreeNode], { connectionId: "conn", database: "db", schema: "db", tableName: "orders" });
expect(target?.type).toBe("table");
});
});
describe("management page sidebar targets", () => {
it.each([
["users", "user-admin"],
["xugu-users", "xugu-user-admin"],
["dameng-users", "dameng-users"],
["dameng-roles", "dameng-roles"],
["dameng-jobs", "dameng-job-admin"],
] as const)("locates %s at its dedicated %s node", (mode, nodeType) => {
const target = activeTabSidebarTarget({ mode, connectionId: "conn" } as QueryTab);
expect(target).toEqual({ type: "management", connectionId: "conn", nodeType });
const node = { id: "management", type: nodeType, connectionId: "conn", label: "translated label" } as TreeNode;
expect(matchesTarget(node, target!)).toBe(true);
expect(matchesTarget({ ...node, type: "connection" }, target!)).toBe(false);
expect(matchesTarget({ ...node, connectionId: "other" }, target!)).toBe(false);
expect(activeTabSidebarTarget({ mode, connectionId: "" } as QueryTab)).toBeNull();
});
});
@@ -7,13 +7,28 @@ export type ActiveTabSidebarTarget =
type: "connection";
connectionId: string;
}
| {
type: "management";
connectionId: string;
nodeType: "user-admin" | "xugu-user-admin" | "dameng-users" | "dameng-roles" | "dameng-job-admin";
}
| {
type: "table";
catalog?: string;
connectionId: string;
database: string;
schema?: string;
tableName: string;
}
| {
type: "redis-db";
connectionId: string;
database: string;
}
| {
type: "mqtt-topic";
connectionId: string;
}
| {
type: "mongo-collection";
connectionId: string;
@@ -110,10 +125,46 @@ export function findSidebarConnectionNode(nodes: readonly TreeNode[], connection
export function activeTabSidebarTarget(tab: QueryTab | undefined | null): ActiveTabSidebarTarget | null {
if (!tab) return null;
const managementNodeTypes = {
users: "user-admin",
"xugu-users": "xugu-user-admin",
"dameng-users": "dameng-users",
"dameng-roles": "dameng-roles",
"dameng-jobs": "dameng-job-admin",
} as const;
if (Object.prototype.hasOwnProperty.call(managementNodeTypes, tab.mode)) {
if (!tab.connectionId) return null;
return { type: "management", connectionId: tab.connectionId, nodeType: managementNodeTypes[tab.mode as keyof typeof managementNodeTypes] };
}
if (tab.mode === "databases") {
return { type: "connection", connectionId: tab.connectionId };
}
if (tab.mode === "objects" || tab.mode === "database-search" || (tab.mode === "structure" && !tab.structureTableName)) {
if (!tab.connectionId) return null;
if (!tab.database) return { type: "connection", connectionId: tab.connectionId };
return {
type: "query-context",
connectionId: tab.connectionId,
database: tab.database,
catalog: tab.objectBrowser?.catalog ?? tab.catalog,
schema: tab.objectBrowser?.schema ?? tab.schema,
};
}
if (tab.mode === "structure") {
return { type: "table", connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema: tab.schema, tableName: tab.structureTableName! };
}
if (tab.mode === "redis") {
return { type: "redis-db", connectionId: tab.connectionId, database: tab.database };
}
if (tab.mode === "mqtt") {
return { type: "mqtt-topic", connectionId: tab.connectionId };
}
if (tab.mode === "data") {
const tableMeta = tableMetaForDataTab(tab);
const tableName = tableMeta?.tableName || tab.title;
@@ -122,6 +173,7 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
type: "table",
connectionId: tab.connectionId,
database: tableMeta?.database ?? tab.database,
...(tab.catalog ? { catalog: tab.catalog } : {}),
schema: tableMeta?.schema ?? tab.schema,
tableName,
};
@@ -229,7 +281,8 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
}
if (tab.mode === "query") {
if (!tab.connectionId || !tab.database) return null;
if (!tab.connectionId) return null;
if (!tab.database) return { type: "connection", connectionId: tab.connectionId };
return {
type: "query-context",
connectionId: tab.connectionId,
@@ -239,7 +292,9 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
};
}
return null;
// Connection workspaces (dashboards, MQ without a tenant, and plugins)
// have no more specific object identity.
return tab.connectionId ? { type: "connection", connectionId: tab.connectionId } : null;
}
function schemaMatches(node: TreeNode, schema: string | undefined): boolean {
@@ -258,6 +313,17 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
return node.type === "connection" && node.connectionId === target.connectionId;
}
if (target.type === "management") {
return node.type === target.nodeType && node.connectionId === target.connectionId;
}
if (target.type === "redis-db") {
return node.type === "redis-db" && node.connectionId === target.connectionId && node.database === target.database;
}
if (target.type === "mqtt-topic") {
return node.type === "mqtt-topic" && node.connectionId === target.connectionId;
}
if (target.type === "mongo-collection") {
if (node.type === "elasticsearch-index") {
return node.connectionId === target.connectionId && node.label === target.collectionName;
@@ -286,7 +352,7 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
if (target.schema) {
return node.type === "schema" && node.connectionId === target.connectionId && node.database === target.database && node.label === target.schema;
}
return node.type === "database" && node.connectionId === target.connectionId && node.label === target.database;
return (node.type === "database" || node.type === "mongo-db" || node.type === "redis-db" || node.type === "vector-database") && node.connectionId === target.connectionId && (node.database ?? node.label) === target.database;
}
if (target.type === "etcd-root") {
@@ -327,7 +393,14 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
return node.type === "saved-sql-file" && node.savedSqlId === target.savedSqlId;
}
return (node.type === "table" || node.type === "view" || node.type === "materialized_view") && node.connectionId === target.connectionId && node.database === target.database && schemaMatches(node, target.schema) && node.label === target.tableName;
return (
(node.type === "table" || node.type === "view" || node.type === "materialized_view") &&
(node.catalog || undefined) === (target.catalog || undefined) &&
node.connectionId === target.connectionId &&
node.database === target.database &&
schemaMatches(node, target.schema) &&
node.label === target.tableName
);
}
export function findSidebarNodeForActiveTab(tab: QueryTab | undefined | null, flatNodes: readonly FlatTreeNode[]): FlatTreeNode | null {
@@ -399,3 +472,14 @@ function findPath(nodes: readonly TreeNode[], predicate: (node: TreeNode) => boo
}
return null;
}
/** Expand loaded ancestors, including runtime Queries containers without metadata load markers. */
export function expandSidebarLocatePath(path: readonly TreeNode[], canToggleLoaded: (node: TreeNode) => boolean): void {
for (let index = 0; index < path.length; index++) {
const node = path[index]!;
const next = path[index + 1];
const runtimeSqlAncestor = (node.type === "saved-sql-root" || node.type === "saved-sql-folder") && !!next && !!node.children?.some((child) => child.id === next.id);
// Keep unloaded table/collection leaves collapsed: their columns were not fetched.
if (!node.isExpanded && (runtimeSqlAncestor || canToggleLoaded(node))) node.isExpanded = true;
}
}
@@ -6,6 +6,7 @@ import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
import type { DatabaseType, QueryTab, TreeNode } from "@/types/database";
export interface QueryCursorTableCandidate {
catalog?: string;
connectionId: string;
database: string;
schema?: string;
@@ -44,7 +45,7 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
if (!tab || tab.mode !== "query" || !tab.connectionId || !tab.database) return null;
const cursor = tab.editorSelection?.head ?? tab.editorSelection?.anchor ?? tab.sql.length;
return queryTableCandidateAtSqlPosition({
const candidate = queryTableCandidateAtSqlPosition({
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
@@ -52,6 +53,7 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
sql: tab.sql,
position: cursor,
});
return candidate && tab.catalog ? { ...candidate, catalog: tab.catalog } : candidate;
}
export function queryTableCandidateAtSqlPosition(input: QueryTableCandidateAtPositionInput): QueryCursorTableCandidate | null {
@@ -158,8 +160,9 @@ function sameIdentifier(left: string | undefined, right: string | undefined): bo
function nodeMatchesCandidate(node: TreeNode, candidate: QueryCursorTableCandidate): boolean {
if (node.type !== "table" && node.type !== "view" && node.type !== "materialized_view") return false;
if (node.connectionId !== candidate.connectionId) return false;
if ((node.catalog || undefined) !== (candidate.catalog || undefined)) return false;
if (!sameIdentifier(node.database, candidate.database)) return false;
if (candidate.schema && !sameIdentifier(node.schema, candidate.schema)) return false;
if (candidate.schema && !sameIdentifier(node.schema || node.database, candidate.schema)) return false;
return sameIdentifier(node.label, candidate.tableName);
}
@@ -170,6 +173,7 @@ export function findLoadedTableTargetForCandidate(nodes: readonly TreeNode[], ca
type: "table",
connectionId: candidate.connectionId,
database: node.database || candidate.database,
...(node.catalog ? { catalog: node.catalog } : {}),
schema: node.schema || candidate.schema,
tableName: node.label,
};
@@ -318,6 +318,7 @@ function isFlatMqConnection(config: ConnectionConfig | undefined): boolean {
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
interface LocateTableTarget {
catalog?: string;
connectionId: string;
database: string;
schema?: string;
@@ -7017,10 +7018,29 @@ export const useConnectionStore = defineStore("connection", () => {
offset: 0,
sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay,
driverProfile: metadataDriverProfile(config),
extra: target.catalog ? { catalog: target.catalog } : undefined,
},
async () => {
await ensureConnected(target.connectionId);
if (target.catalog) {
const parent = findDatabaseTreeNode(treeNodes.value, target.connectionId, target.database, target.catalog);
if (!parent) return false;
let load = beginTreeNodeLoad(parent);
try {
load = reclaimTreeNodeLoad(load, parent);
const tables = await withMetadataLoadTimeout(target.connectionId, listTablesWithOptionalTableNameFilter(target.connectionId, target.database, "", target.tableName, undefined, undefined, undefined, target.catalog), "tables");
const current = treeNodeLoadTarget(load);
if (!current) return false;
const children = buildTableTreeNodes({ nodeId: current.id, connectionId: target.connectionId, database: target.database, catalog: target.catalog, tables });
setChildren(current, mergeLocatedTreeChildren(current, current.children ?? [], children, target.connectionId, target.database));
current.isExpanded = true;
return children.length > 0;
} finally {
finishTreeNodeLoad(load);
}
}
const pageSize = sidebarObjectGroupPageSize();
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
// A query tab can reach locate without a schema (a toolbar "new query" tab,