From 8942356eed46f1fc0891e5bcf125fa8905a2968c Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 5 Jun 2026 16:44:41 +0800 Subject: [PATCH] fix: sort sidebar metadata nodes consistently --- apps/desktop/src/lib/databaseTree.ts | 16 ++++- apps/desktop/src/lib/sidebarNodeOrdering.ts | 68 +++++++++++++++++++ apps/desktop/src/lib/sqlServerTree.ts | 23 +++---- apps/desktop/src/stores/connectionStore.ts | 16 +++-- crates/dbx-core/src/mongo_ops.rs | 35 ++++++++-- packages/app-tests/databaseTree.test.ts | 23 +++++-- .../app-tests/sidebarNodeOrdering.test.ts | 62 +++++++++++++++++ packages/app-tests/sqlServerTree.test.ts | 10 ++- 8 files changed, 219 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/lib/sidebarNodeOrdering.ts create mode 100644 packages/app-tests/sidebarNodeOrdering.test.ts diff --git a/apps/desktop/src/lib/databaseTree.ts b/apps/desktop/src/lib/databaseTree.ts index 8c1b38e16..a0dc5edbf 100644 --- a/apps/desktop/src/lib/databaseTree.ts +++ b/apps/desktop/src/lib/databaseTree.ts @@ -1,6 +1,8 @@ import type { ConnectionConfig, DatabaseInfo, TreeNode } from "@/types/database"; import { DEFAULT_DATABASE_TREE_LABEL } from "./treeNodeContext"; +const sidebarNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + export function shouldIncludeDefaultDatabaseNode( connection: Pick | undefined, databases: DatabaseInfo[], @@ -8,12 +10,20 @@ export function shouldIncludeDefaultDatabaseNode( return connection?.db_type === "mysql" && databases.some((database) => !database.name.trim()); } +export function sortSidebarNames(names: readonly string[]): string[] { + return [...names].sort((left, right) => sidebarNameCollator.compare(left, right)); +} + +export function sortSidebarDatabases(databases: readonly DatabaseInfo[]): DatabaseInfo[] { + return [...databases].sort((left, right) => sidebarNameCollator.compare(left.name, right.name)); +} + export function buildDatabaseTreeNodes( connectionId: string, databases: DatabaseInfo[], options: { includeDefaultWhenEmpty?: boolean } = {}, ): TreeNode[] { - const nodes = databases.flatMap((db) => { + const nodes = sortSidebarDatabases(databases).flatMap((db) => { const name = db.name.trim(); if (!name) return []; return [ @@ -49,7 +59,7 @@ export function buildDuckDbConnectionTreeNodes( databases: DatabaseInfo[], primarySchemas: string[], ): TreeNode[] { - const schemaNodes = primarySchemas.flatMap((schema) => { + const schemaNodes = sortSidebarNames(primarySchemas).flatMap((schema) => { const name = schema.trim(); if (!name) return []; return [ @@ -66,7 +76,7 @@ export function buildDuckDbConnectionTreeNodes( ]; }); - const attachedCatalogNodes = databases.flatMap((db) => { + const attachedCatalogNodes = sortSidebarDatabases(databases).flatMap((db) => { const name = db.name.trim(); if (!name || name === "main") return []; return [ diff --git a/apps/desktop/src/lib/sidebarNodeOrdering.ts b/apps/desktop/src/lib/sidebarNodeOrdering.ts new file mode 100644 index 000000000..ed9853940 --- /dev/null +++ b/apps/desktop/src/lib/sidebarNodeOrdering.ts @@ -0,0 +1,68 @@ +import type { DatabaseType, TreeNode } from "@/types/database"; + +const sidebarTreeCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +function sortByLabel(nodes: readonly TreeNode[]): TreeNode[] { + return [...nodes].sort((left, right) => sidebarTreeCollator.compare(left.label, right.label)); +} + +function sortRecursive(node: TreeNode, databaseType?: DatabaseType): TreeNode { + const children = node.children ? sortSidebarTreeChildrenForParent(node, node.children, databaseType) : node.children; + const hiddenChildren = node.hiddenChildren + ? sortSidebarTreeChildrenForParent(node, node.hiddenChildren, databaseType) + : node.hiddenChildren; + if (children === node.children && hiddenChildren === node.hiddenChildren) return node; + return { + ...node, + children, + hiddenChildren, + }; +} + +export function sortSidebarTreeChildrenForParent( + parent: Pick, + children: readonly TreeNode[], + databaseType?: DatabaseType, +): TreeNode[] { + const normalized = children.map((child) => sortRecursive(child, databaseType)); + + if (parent.type === "mongo-db") { + return sortByLabel(normalized); + } + + if (parent.type === "connection") { + if (databaseType === "mongodb" || databaseType === "elasticsearch") { + return sortByLabel(normalized); + } + + if (databaseType === "duckdb") { + const schemas = sortByLabel(normalized.filter((child) => child.type === "schema")); + const databases = sortByLabel(normalized.filter((child) => child.type === "database")); + const rest = normalized.filter((child) => child.type !== "schema" && child.type !== "database"); + return [...schemas, ...databases, ...rest]; + } + + if (normalized.every((child) => child.type === "database")) { + return sortByLabel(normalized); + } + + if (normalized.every((child) => child.type === "schema")) { + return sortByLabel(normalized); + } + } + + if (parent.type === "database") { + if (databaseType === "sqlserver") { + const objectGroups = normalized.filter((child) => child.type.startsWith("group-")); + const schemas = sortByLabel(normalized.filter((child) => child.type === "schema")); + const rest = normalized.filter((child) => !child.type.startsWith("group-") && child.type !== "schema"); + return [...objectGroups, ...schemas, ...rest]; + } + + if (normalized.every((child) => child.type === "schema")) { + return sortByLabel(normalized); + } + } + + return normalized; +} diff --git a/apps/desktop/src/lib/sqlServerTree.ts b/apps/desktop/src/lib/sqlServerTree.ts index eb1b98cdc..adbb4df36 100644 --- a/apps/desktop/src/lib/sqlServerTree.ts +++ b/apps/desktop/src/lib/sqlServerTree.ts @@ -1,4 +1,5 @@ import type { ObjectInfo, TreeNode } from "@/types/database"; +import { sortSidebarNames } from "@/lib/databaseTree"; import { buildGroupedObjectTreeNodes, buildSimpleObjectTreeNodes } from "@/lib/tableTree"; export const SQLSERVER_DEFAULT_SCHEMA = "dbo"; @@ -33,18 +34,16 @@ export function buildSqlServerDatabaseTreeNodes( objects: defaultSchemaObjects, }); - const schemaNodes = schemas - .filter((schema) => !isDefaultSchema(schema)) - .map((schema) => ({ - id: `${databaseNodeId}:${schema}`, - label: schema, - type: "schema" as const, - connectionId, - database, - schema, - isExpanded: false, - children: [], - })); + const schemaNodes = sortSidebarNames(schemas.filter((schema) => !isDefaultSchema(schema))).map((schema) => ({ + id: `${databaseNodeId}:${schema}`, + label: schema, + type: "schema" as const, + connectionId, + database, + schema, + isExpanded: false, + children: [], + })); return [...defaultObjectNodes, ...schemaNodes]; } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index d1814d86e..58982bb25 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -29,6 +29,7 @@ import { import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, + sortSidebarNames, shouldIncludeDefaultDatabaseNode, } from "@/lib/databaseTree"; import { buildSqlServerDatabaseTreeNodes, SQLSERVER_DEFAULT_SCHEMA } from "@/lib/sqlServerTree"; @@ -54,6 +55,7 @@ import { treeNodeSchemaCachePrefix, } from "@/lib/treeNodeContext"; import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/schemaTreeCache"; +import { sortSidebarTreeChildrenForParent } from "@/lib/sidebarNodeOrdering"; import { prunePinnedTreeNodeIdsForConnection } from "@/lib/pinnedTreeNodeIds"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useSettingsStore } from "@/stores/settingsStore"; @@ -451,7 +453,11 @@ export const useConnectionStore = defineStore("connection", () => { const payload = await api.loadSchemaCache(cacheKey).catch(() => null); const decoded = decodeSchemaTreeCache(payload); if (!decoded) return { hit: false, isStale: false }; - const normalizedChildren = normalizeCataloglessDatabaseNodes(expandCachedObjectBrowserNodes(decoded.children)); + const normalizedChildren = sortSidebarTreeChildrenForParent( + node, + normalizeCataloglessDatabaseNodes(expandCachedObjectBrowserNodes(decoded.children)), + node.connectionId ? getConfig(node.connectionId)?.db_type : undefined, + ); setChildren( node, node.type === "connection" && node.connectionId @@ -830,7 +836,7 @@ export const useConnectionStore = defineStore("connection", () => { } const schemas = await api.listSchemas(connectionId, effectiveDb); const visibleSchemas = filterDatabaseNamesForConnection(schemas, config); - const schemaNodes: TreeNode[] = visibleSchemas.map((s) => ({ + const schemaNodes: TreeNode[] = sortSidebarNames(visibleSchemas).map((s) => ({ id: `${connectionId}:${s}:${s}`, label: s, type: "schema" as const, @@ -950,7 +956,7 @@ export const useConnectionStore = defineStore("connection", () => { node, withSavedSqlRoot( connectionId, - visibleDbs.map((db) => ({ + sortSidebarNames(visibleDbs).map((db) => ({ id: `${connectionId}:${db}`, label: db, type: "mongo-db" as const, @@ -981,7 +987,7 @@ export const useConnectionStore = defineStore("connection", () => { const collections = await api.mongoListCollections(connectionId, database); setChildren( node, - collections.map((col) => ({ + sortSidebarNames(collections).map((col) => ({ id: `${nodeId}:${col}`, label: col, type: "mongo-collection" as const, @@ -1016,7 +1022,7 @@ export const useConnectionStore = defineStore("connection", () => { } } - const schemas = await api.listSchemas(connectionId, database); + const schemas = sortSidebarNames(await api.listSchemas(connectionId, database)); const children = schemas.map((s) => ({ id: `${connectionId}:${database}:${s}`, label: s, diff --git a/crates/dbx-core/src/mongo_ops.rs b/crates/dbx-core/src/mongo_ops.rs index 30bb686d7..f932dba63 100644 --- a/crates/dbx-core/src/mongo_ops.rs +++ b/crates/dbx-core/src/mongo_ops.rs @@ -3,12 +3,21 @@ use crate::db::agent_driver::mongo_document_id_params; use crate::db::elasticsearch_driver; use crate::db::mongo_driver::{self, MongoDocumentResult}; +fn sort_names(mut names: Vec) -> Vec { + names.sort_by(|left, right| { + let left_lower = left.to_lowercase(); + let right_lower = right.to_lowercase(); + left_lower.cmp(&right_lower).then_with(|| left.cmp(right)) + }); + names +} + pub async fn mongo_list_databases_core(state: &AppState, connection_id: &str) -> Result, String> { let fallback_database = configured_mongo_database(state, connection_id).await; let connections = state.connections.read().await; match connections.get(connection_id).ok_or("Not found")? { PoolKind::MongoDb(client) => match mongo_driver::list_databases(client).await { - Ok(databases) => Ok(databases), + Ok(databases) => Ok(sort_names(databases)), Err(error) if mongo_list_databases_unauthorized(&error) => { fallback_mongo_database(&error, fallback_database) } @@ -18,7 +27,9 @@ pub async fn mongo_list_databases_core(state: &AppState, connection_id: &str) -> PoolKind::Agent(client) => { let mut client = client.lock().await; match client.mongo_list_databases::>().await { - Ok(result) => Ok(result.iter().filter_map(|v| v.get("name")?.as_str().map(String::from)).collect()), + Ok(result) => { + Ok(sort_names(result.iter().filter_map(|v| v.get("name")?.as_str().map(String::from)).collect())) + } Err(error) if mongo_list_databases_unauthorized(&error) => { fallback_mongo_database(&error, fallback_database) } @@ -50,11 +61,11 @@ pub async fn mongo_list_collections_core( ) -> Result, String> { let connections = state.connections.read().await; match connections.get(connection_id).ok_or("Not found")? { - PoolKind::MongoDb(client) => mongo_driver::list_collections(client, database).await, - PoolKind::Elasticsearch(client) => elasticsearch_driver::list_indices(client).await, + PoolKind::MongoDb(client) => mongo_driver::list_collections(client, database).await.map(sort_names), + PoolKind::Elasticsearch(client) => elasticsearch_driver::list_indices(client).await.map(sort_names), PoolKind::Agent(client) => { let mut client = client.lock().await; - client.mongo_list_collections(database).await + client.mongo_list_collections(database).await.map(sort_names) } _ => Err("Not a MongoDB/Elasticsearch connection".to_string()), } @@ -257,7 +268,19 @@ pub async fn mongo_delete_documents_core( #[cfg(test)] mod tests { - use super::{fallback_mongo_database, mongo_list_databases_unauthorized}; + use super::{fallback_mongo_database, mongo_list_databases_unauthorized, sort_names}; + + #[test] + fn sorts_names_case_insensitively() { + let sorted = sort_names(vec![ + "movies".to_string(), + "Comments".to_string(), + "users".to_string(), + "embedded_movies".to_string(), + ]); + + assert_eq!(sorted, vec!["Comments", "embedded_movies", "movies", "users"]); + } #[test] fn detects_mongo_list_databases_unauthorized_errors() { diff --git a/packages/app-tests/databaseTree.test.ts b/packages/app-tests/databaseTree.test.ts index 76e45ab29..3aa7b8750 100644 --- a/packages/app-tests/databaseTree.test.ts +++ b/packages/app-tests/databaseTree.test.ts @@ -3,15 +3,22 @@ import test from "node:test"; import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, + sortSidebarNames, shouldIncludeDefaultDatabaseNode, } from "../../apps/desktop/src/lib/databaseTree.ts"; -test("设置默认库后侧边栏数据库树仍保留全部数据库", () => { - const nodes = buildDatabaseTreeNodes("conn-1", [{ name: "campaign_data" }, { name: "cms" }, { name: "mk_campaign" }]); +test("数据库节点按自然名称排序", () => { + const nodes = buildDatabaseTreeNodes("conn-1", [ + { name: "db10" }, + { name: "db2" }, + { name: "campaign_data" }, + { name: "cms" }, + { name: "mk_campaign" }, + ]); assert.deepEqual( nodes.map((node) => node.database), - ["campaign_data", "cms", "mk_campaign"], + ["campaign_data", "cms", "db2", "db10", "mk_campaign"], ); assert.equal(nodes.find((node) => node.database === "mk_campaign")?.id, "conn-1:mk_campaign"); }); @@ -49,8 +56,8 @@ test("MySQL-compatible catalogless services can opt into the default database no test("DuckDB shows primary catalog schemas directly under the connection", () => { const nodes = buildDuckDbConnectionTreeNodes( "conn-1", - [{ name: "main" }, { name: "attached_reports" }], - ["main", "mysql", "prod_sales"], + [{ name: "main" }, { name: "attached_reports" }, { name: "analytics_20" }, { name: "analytics_3" }], + ["prod_sales", "main", "mysql"], ); assert.deepEqual( @@ -59,8 +66,14 @@ test("DuckDB shows primary catalog schemas directly under the connection", () => ["schema", "main", "main", "main"], ["schema", "mysql", "main", "mysql"], ["schema", "prod_sales", "main", "prod_sales"], + ["database", "analytics_3", "analytics_3", undefined], + ["database", "analytics_20", "analytics_20", undefined], ["database", "attached_reports", "attached_reports", undefined], ], ); assert.equal(nodes.find((node) => node.label === "mysql")?.id, "conn-1:main:mysql"); }); + +test("sidebar name sorting uses numeric-aware ordering", () => { + assert.deepEqual(sortSidebarNames(["db10", "db2", "db1"]), ["db1", "db2", "db10"]); +}); diff --git a/packages/app-tests/sidebarNodeOrdering.test.ts b/packages/app-tests/sidebarNodeOrdering.test.ts new file mode 100644 index 000000000..116b8610d --- /dev/null +++ b/packages/app-tests/sidebarNodeOrdering.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { sortSidebarTreeChildrenForParent } from "../../apps/desktop/src/lib/sidebarNodeOrdering.ts"; +import type { TreeNode } from "../../apps/desktop/src/types/database.ts"; + +test("reorders cached MongoDB collections alphabetically", () => { + const parent: Pick = { type: "mongo-db" }; + const children: TreeNode[] = [ + { id: "c:db:comments", label: "comments", type: "mongo-collection" }, + { id: "c:db:movies", label: "movies", type: "mongo-collection" }, + { id: "c:db:sessions", label: "sessions", type: "mongo-collection" }, + ]; + + const sorted = sortSidebarTreeChildrenForParent(parent, [children[2], children[0], children[1]], "mongodb"); + + assert.deepEqual( + sorted.map((child) => child.label), + ["comments", "movies", "sessions"], + ); +}); + +test("keeps SQL Server object groups first and sorts schema children after them", () => { + const parent: Pick = { type: "database" }; + const children: TreeNode[] = [ + { id: "conn:app:zeta", label: "zeta", type: "schema" }, + { id: "conn:app:__tables", label: "tree.tables", type: "group-tables" }, + { id: "conn:app:archive", label: "archive", type: "schema" }, + ]; + + const sorted = sortSidebarTreeChildrenForParent(parent, children, "sqlserver"); + + assert.deepEqual( + sorted.map((child) => [child.type, child.label]), + [ + ["group-tables", "tree.tables"], + ["schema", "archive"], + ["schema", "zeta"], + ], + ); +}); + +test("keeps DuckDB schemas before attached catalogs while sorting both", () => { + const parent: Pick = { type: "connection" }; + const children: TreeNode[] = [ + { id: "conn:analytics_20", label: "analytics_20", type: "database" }, + { id: "conn:main:mysql", label: "mysql", type: "schema" }, + { id: "conn:analytics_3", label: "analytics_3", type: "database" }, + { id: "conn:main:main", label: "main", type: "schema" }, + ]; + + const sorted = sortSidebarTreeChildrenForParent(parent, children, "duckdb"); + + assert.deepEqual( + sorted.map((child) => [child.type, child.label]), + [ + ["schema", "main"], + ["schema", "mysql"], + ["database", "analytics_3"], + ["database", "analytics_20"], + ], + ); +}); diff --git a/packages/app-tests/sqlServerTree.test.ts b/packages/app-tests/sqlServerTree.test.ts index 6554a2a72..bd4416bbd 100644 --- a/packages/app-tests/sqlServerTree.test.ts +++ b/packages/app-tests/sqlServerTree.test.ts @@ -11,7 +11,7 @@ function obj(name: string, objectType = "TABLE"): ObjectInfo { } test("SQL Server database tree groups dbo objects and keeps non-default schemas", () => { - const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "sales"], [ + const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "zeta", "sales"], [ obj("customers"), obj("customer_view", "VIEW"), obj("get_total", "FUNCTION"), @@ -23,6 +23,7 @@ test("SQL Server database tree groups dbo objects and keeps non-default schemas" { id: "conn:app:__views", label: "tree.views", type: "group-views" }, { id: "conn:app:__functions", label: "tree.functions", type: "group-functions" }, { id: "conn:app:sales", label: "sales", type: "schema" }, + { id: "conn:app:zeta", label: "zeta", type: "schema" }, ]); const tableGroup = nodes.find((n) => n.type === "group-tables"); @@ -39,11 +40,14 @@ test("SQL Server database tree groups dbo objects and keeps non-default schemas" }); test("SQL Server database tree shows only schemas when dbo has no objects", () => { - const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "archive"], []); + const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "archive", "beta"], []); assert.deepEqual( nodes.map((node) => ({ id: node.id, label: node.label, type: node.type })), - [{ id: "conn:app:archive", label: "archive", type: "schema" }], + [ + { id: "conn:app:archive", label: "archive", type: "schema" }, + { id: "conn:app:beta", label: "beta", type: "schema" }, + ], ); });