From 758d21167a65becb7fb3346e61358c8581ce0dff Mon Sep 17 00:00:00 2001 From: zipg Date: Sun, 20 Sep 2026 12:52:50 +0800 Subject: [PATCH] fix(sidebar): pin vgroup rows before flat rows on sort --- .../sidebarNodeOrdering.tableVGroup.spec.ts | 46 +++++++++++++++++++ .../src/lib/sidebar/sidebarNodeOrdering.ts | 31 +++++++++++++ apps/desktop/src/stores/connectionStore.ts | 9 +--- crates/dbx-drivers/src/db/ssh_tunnel.rs | 37 ++++++++++++++- 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/sidebar/sidebarNodeOrdering.tableVGroup.spec.ts diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarNodeOrdering.tableVGroup.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarNodeOrdering.tableVGroup.spec.ts new file mode 100644 index 0000000000..82c947a6cc --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarNodeOrdering.tableVGroup.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { sortSidebarTreeChildrenByNameKeepingTableVGroups } from "@/lib/sidebar/sidebarNodeOrdering"; +import { applyTableVGroupsToChildren, createTableVGroup, emptyTableVGroupLayout, moveTableToVGroup } from "@/lib/table/tableVGroup"; +import type { TreeNode } from "@/types/database"; + +const SCOPE = { connectionId: "conn-1", database: "db1" }; + +function tableNode(name: string): TreeNode { + return { id: `table:${name}`, label: name, type: "table", connectionId: "conn-1", database: "db1" }; +} + +/** Mirrors connectionStore.mergeLocatedTreeChildren: merge a page, then re-sort. */ +function mergePageLikeStore(parent: TreeNode, children: TreeNode[], page: TreeNode[]): TreeNode[] { + return sortSidebarTreeChildrenByNameKeepingTableVGroups(parent, [...children, ...page], "mysql"); +} + +describe("sidebar ordering keeps projected table groups pinned", () => { + it("keeps a virtual table group at the top after a paged merge re-sorts children by name", () => { + const created = createTableVGroup(emptyTableVGroupLayout(), "zzz-分组"); + const layout = moveTableToVGroup(created.layout, "t_order", created.groupId); + const parent: TreeNode = { id: "group-tables:db1", label: "tables", type: "group-tables", connectionId: "conn-1", database: "db1" }; + + // What the sidebar shows after the layout projection runs. + const projected = applyTableVGroupsToChildren([tableNode("t_user"), tableNode("t_order")], layout, SCOPE); + expect(projected[0]!.type).toBe("table-vgroup"); + + // A sidebar page merge must not move the projected group out of the top + // slot: the layout, not the alphabet, decides where a group sits. + const merged = mergePageLikeStore(parent, projected, [tableNode("t_zzz")]); + expect(merged[0]!.type).toBe("table-vgroup"); + expect(merged.slice(1).map((node) => node.label)).toEqual(["t_user", "t_zzz"]); + expect(merged[0]!.children?.map((node) => node.label)).toEqual(["t_order"]); + }); + + it("keeps projected groups in layout order when their labels sort differently", () => { + const first = createTableVGroup(emptyTableVGroupLayout(), "b-组"); + let layout = moveTableToVGroup(first.layout, "t_order", first.groupId); + const second = createTableVGroup(layout, "a-组"); + layout = moveTableToVGroup(second.layout, "t_pay", second.groupId); + const parent: TreeNode = { id: "group-tables:db1", label: "tables", type: "group-tables", connectionId: "conn-1", database: "db1" }; + + const projected = applyTableVGroupsToChildren([tableNode("t_user"), tableNode("t_order"), tableNode("t_pay")], layout, SCOPE); + const merged = mergePageLikeStore(parent, projected, [tableNode("t_zzz")]); + expect(merged.filter((node) => node.type === "table-vgroup").map((node) => node.label)).toEqual(["b-组", "a-组"]); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/sidebarNodeOrdering.ts b/apps/desktop/src/lib/sidebar/sidebarNodeOrdering.ts index 29477e4d3f..0e4db38834 100644 --- a/apps/desktop/src/lib/sidebar/sidebarNodeOrdering.ts +++ b/apps/desktop/src/lib/sidebar/sidebarNodeOrdering.ts @@ -17,7 +17,38 @@ function sortRecursive(node: TreeNode, databaseType?: DatabaseType): TreeNode { }; } +/** + * Virtual table groups are a display-level projection (see + * `applyTableVGroupsToChildren`) whose order comes from the stored layout, not + * from the alphabet. Every ordering pass therefore has to leave them ahead of + * the flat rows: a name-sorted metadata merge would otherwise drop a group back + * to its alphabetical slot, and the row would visibly jump from the top of the + * list to the bottom until the next projection ran (issues #9644 / #9653). + */ +function hoistTableVGroupChildren(nodes: TreeNode[]): TreeNode[] { + if (!nodes.some((node) => node.type === "table-vgroup")) return nodes; + return [...nodes.filter((node) => node.type === "table-vgroup"), ...nodes.filter((node) => node.type !== "table-vgroup")]; +} + export function sortSidebarTreeChildrenForParent(parent: Pick, children: readonly TreeNode[], databaseType?: DatabaseType): TreeNode[] { + return hoistTableVGroupChildren(orderSidebarTreeChildrenForParent(parent, children, databaseType)); +} + +/** + * Orders a container whose rows are merged from paged metadata: only the flat + * rows are sorted by label, while projected virtual table groups keep the + * position (and relative order) the layout projection gave them. Sorting the + * groups by name would move them out of the top slot they were projected into + * (issues #9644 / #9653). + */ +export function sortSidebarTreeChildrenByNameKeepingTableVGroups(parent: Pick, children: readonly TreeNode[], databaseType?: DatabaseType): TreeNode[] { + const groups = children.filter((node) => node.type === "table-vgroup"); + const flat = children.filter((node) => node.type !== "table-vgroup"); + const orderedFlat = [...flat].sort((left, right) => sidebarTreeCollator.compare(left.label, right.label)); + return sortSidebarTreeChildrenForParent(parent, [...groups, ...orderedFlat], databaseType); +} + +function orderSidebarTreeChildrenForParent(parent: Pick, children: readonly TreeNode[], databaseType?: DatabaseType): TreeNode[] { const normalized = children.map((child) => sortRecursive(child, databaseType)); if (parent.type === "mongo-db") { diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 687bd87be6..ce2febc74d 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -147,7 +147,6 @@ import { mergeTableTreePageChildren, objectGroupRefreshParentId, objectTypesForGroupNode, - sortDatabaseObjectsByName, tablePartitionGroups, withoutTableTreeLoadMoreNodes, type TableTreeLoadMoreParent, @@ -155,7 +154,7 @@ import { } from "@/lib/table/tableTree"; import { hasTreeNodeDatabaseContext, normalizeCataloglessDatabaseNodes, treeNodeSchemaCachePrefix } from "@/lib/sidebar/treeNodeContext"; import { decodeSchemaTreeCache, decodeTableSearchIndexManifest, encodeSchemaTreeCache, encodeTableSearchIndexManifest, type TableSearchIndexManifestEntry } from "@/lib/metadata/schemaTreeCache"; -import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering"; +import { sortSidebarTreeChildrenByNameKeepingTableVGroups, sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering"; import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin"; import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities"; import { mergeRedisCommandDocumentation, parseRedisCommandCatalog, parseRedisCommandDocumentation, type RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs"; @@ -2435,11 +2434,7 @@ export const useConnectionStore = defineStore("connection", () => { existing.set(key, child); } const config = parent.connectionId ? getConfig(parent.connectionId) : undefined; - return sortSidebarTreeChildrenForParent( - parent, - sortDatabaseObjectsByName(merged, (node) => node.label), - config?.db_type, - ); + return sortSidebarTreeChildrenByNameKeepingTableVGroups(parent, merged, config?.db_type); } function findTreeNodes(nodes: TreeNode[], predicate: (node: TreeNode) => boolean): TreeNode[] { diff --git a/crates/dbx-drivers/src/db/ssh_tunnel.rs b/crates/dbx-drivers/src/db/ssh_tunnel.rs index dde34f4482..93b315c7bc 100644 --- a/crates/dbx-drivers/src/db/ssh_tunnel.rs +++ b/crates/dbx-drivers/src/db/ssh_tunnel.rs @@ -14,7 +14,7 @@ use russh::keys::ssh_key::HashAlg; use russh::keys::{decode_secret_key, key::PrivateKeyWithHashAlg, PrivateKey}; use russh::MethodKind; use russh::MethodSet; -use russh::{kex, mac, ChannelOpenFailure, Preferred}; +use russh::{cipher, kex, mac, ChannelOpenFailure, Preferred}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::sync::{mpsc, Mutex}; @@ -206,6 +206,21 @@ fn ssh_client_config() -> Config { } preferred.mac = Cow::Owned(mac); + let mut ciphers = preferred.cipher.into_owned(); + // AES-CBC stays available as a last-resort fallback for old or appliance SSH + // daemons whose entire offer is CBC (for example `aes256-cbc,aes128-cbc`), + // which otherwise abort the handshake with "No common Cipher algorithm" + // before authentication is ever attempted. The entries are appended behind + // russh's safe defaults, so a capable server never negotiates down to CBC. + // 3DES/blowfish/cast128/arcfour are deliberately left out: they are weaker + // still, and a CBC-only daemon finds AES first in the same offer. + for algorithm in [cipher::AES_256_CBC, cipher::AES_192_CBC, cipher::AES_128_CBC] { + if !ciphers.contains(&algorithm) { + ciphers.push(algorithm); + } + } + preferred.cipher = Cow::Owned(ciphers); + Config { nodelay: true, keepalive_interval: Some(Duration::from_secs(30)), @@ -2111,6 +2126,26 @@ mod tests { assert_eq!(effective_hop_timeout(&tunnel), default_ssh_connect_timeout_secs()); } + #[test] + fn ssh_client_config_keeps_legacy_cbc_ciphers_after_safe_defaults() { + let config = ssh_client_config(); + let ciphers = config.preferred.cipher; + let position = |needle: russh::cipher::Name| ciphers.iter().position(|algorithm| *algorithm == needle).unwrap(); + + // A CBC-only daemon (issue #9397 offered aes256-cbc/aes128-cbc and + // nothing else) only finds a common cipher through these fallbacks. + let aes256_cbc_index = position(russh::cipher::AES_256_CBC); + let aes128_cbc_index = position(russh::cipher::AES_128_CBC); + let gcm_index = position(russh::cipher::AES_256_GCM); + let ctr_index = position(russh::cipher::AES_128_CTR); + + // Every safe default stays ahead of CBC, so a capable server still + // negotiates a modern cipher. + assert!(gcm_index < aes256_cbc_index); + assert!(ctr_index < aes256_cbc_index); + assert!(aes256_cbc_index < aes128_cbc_index); + } + #[test] fn ssh_client_config_keeps_legacy_kex_after_safe_defaults() { let config = ssh_client_config();