fix: sort sidebar metadata nodes consistently

This commit is contained in:
t8y2 2026-06-05 16:44:41 +08:00
parent 8d69904ea1
commit 8942356eed
8 changed files with 219 additions and 34 deletions

View File

@ -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<ConnectionConfig, "db_type"> | 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 [

View File

@ -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<TreeNode, "type">,
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;
}

View File

@ -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];
}

View File

@ -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<unknown>(cacheKey).catch(() => null);
const decoded = decodeSchemaTreeCache<TreeNode[]>(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,

View File

@ -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<String>) -> Vec<String> {
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<Vec<String>, 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::<Vec<serde_json::Value>>().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<Vec<String>, 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() {

View File

@ -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"]);
});

View File

@ -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<TreeNode, "type"> = { 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<TreeNode, "type"> = { 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<TreeNode, "type"> = { 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"],
],
);
});

View File

@ -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" },
],
);
});