refactor(catalog): 目录树投影收进 CatalogTreeSnapshot

visibleRows(collapsed:)、serverLeafCount 与共享行类型 CatalogTreeRow
移入树快照,服务器管理侧栏、首页目标树与工作区角标三处各自重走的
遍历归一;HomeServerListState.visibleRows 保留为委托门面,视图与
测试零改动。嵌套菜单构造(TargetCascade)与政策查询
(moveDestinations)非投影重复,有意不动。
This commit is contained in:
Qiu Yuzhou
2026-10-03 14:29:12 +08:00
parent cde430c5fc
commit 57ce356573
6 changed files with 112 additions and 47 deletions
@@ -61,6 +61,15 @@ extension CatalogTreeNode {
}
}
/// 侧栏树的可见行投影:节点 + 呈现深度;身份即节点身份(折叠只影响可见
/// 集合,不影响行身份与选中)。服务器管理侧栏与首页目标树共用同一类型,
/// 折叠集合由各自的浏览状态 owner 给出。
struct CatalogTreeRow: Identifiable {
let node: CatalogTreeNode
let depth: Int
var id: NodeID { node.id }
}
/// 目录树快照:侧栏、活动目标级联、移动目的地与删除确认共用的同一份
/// 非敏感 projection;UI 不再持有原始目录副本(issue #41)。
struct CatalogTreeSnapshot: Equatable {
@@ -97,6 +106,32 @@ struct CatalogTreeSnapshot: Equatable {
}
}
extension CatalogTreeSnapshot {
/// 折叠投影后的可见行(深度优先):收起分组的子树不出现,其余保持目录
/// 序。行序与深度是纯树事实;折叠集合由调用方按自己的持久化策略持有。
func visibleRows(collapsed: Set<NodeID>) -> [CatalogTreeRow] {
var rows: [CatalogTreeRow] = []
func walk(_ nodes: [CatalogTreeNode], depth: Int) {
for node in nodes {
rows.append(CatalogTreeRow(node: node, depth: depth))
if node.isGroup, !collapsed.contains(node.id) {
walk(node.childNodes, depth: depth + 1)
}
}
}
walk(roots, depth: 0)
return rows
}
/// 全树服务器叶子数(工作区侧栏角标)。
var serverLeafCount: Int {
func leaves(_ nodes: [CatalogTreeNode]) -> Int {
nodes.reduce(0) { $0 + ($1.isGroup ? leaves($1.childNodes) : 1) }
}
return leaves(roots)
}
}
extension CatalogTreeSnapshot {
/// 从已提交目录构建 projection(module 内部推导;测试同 target 可直接调用)。
/// 每个服务器叶子求值一次激活校验(与既有口径一致,读凭据存储)。
+3 -18
View File
@@ -1,12 +1,6 @@
import Combine
import Foundation
struct HomeServerTreeRow: Identifiable {
let node: CatalogTreeNode
let depth: Int
var id: NodeID { node.id }
}
/// 首页独立的浏览状态。选择与折叠不改变目录或运行时活动目标。
@MainActor
final class HomeServerListState: ObservableObject {
@@ -34,18 +28,9 @@ final class HomeServerListState: ObservableObject {
}
}
var visibleRows: [HomeServerTreeRow] {
var rows: [HomeServerTreeRow] = []
func walk(_ nodes: [CatalogTreeNode], depth: Int) {
for node in nodes {
rows.append(HomeServerTreeRow(node: node, depth: depth))
if node.isGroup, !collapsedGroupIDs.contains(node.id) {
walk(node.childNodes, depth: depth + 1)
}
}
}
walk(tree.roots, depth: 0)
return rows
/// 可见行投影委托树快照;折叠集合由本状态的持久化策略持有。
var visibleRows: [CatalogTreeRow] {
tree.visibleRows(collapsed: collapsedGroupIDs)
}
func update(tree: CatalogTreeSnapshot, activeTargetID: NodeID?) {
@@ -192,7 +192,7 @@ struct TargetTreeCard: View {
private struct HomeServerListRow: View {
@Environment(\.controlActiveState) private var controlActiveState
let row: HomeServerTreeRow
let row: CatalogTreeRow
let eligibility: ActivationEligibility?
let isSelected: Bool
let isActive: Bool
+1 -4
View File
@@ -340,10 +340,7 @@ extension MainWindowView {
}
private var serverLeafCount: Int {
func leaves(_ nodes: [CatalogTreeNode]) -> Int {
nodes.reduce(0) { $0 + ($1.isGroup ? leaves($1.childNodes) : 1) }
}
return leaves(workflow.tree.roots)
workflow.tree.serverLeafCount
}
// MARK: - 底部代理状态卡
+1 -24
View File
@@ -91,7 +91,7 @@ struct ServersView: View {
private var serverSidebar: some View {
List(selection: $selection) {
ForEach(visibleRows) { row in
ForEach(workflow.tree.visibleRows(collapsed: expansion.collapsedGroupIDs)) { row in
treeRow(row)
}
}
@@ -113,21 +113,6 @@ struct ServersView: View {
.toolbar { toolbarContent }
}
/// 折叠投影后的可见行(深度优先):收起分组的子树不出现,其余保持目录序。
private var visibleRows: [CatalogTreeRow] {
var rows: [CatalogTreeRow] = []
func walk(_ nodes: [CatalogTreeNode], depth: Int) {
for node in nodes {
rows.append(CatalogTreeRow(node: node, depth: depth))
if node.isGroup, !expansion.isCollapsed(node.id) {
walk(node.childNodes, depth: depth + 1)
}
}
}
walk(workflow.tree.roots, depth: 0)
return rows
}
private func treeRow(_ row: CatalogTreeRow) -> some View {
let node = row.node
return HStack(spacing: 4) {
@@ -354,11 +339,3 @@ private struct MoveContext: Identifiable {
let nodeID: NodeID
var id: NodeID { nodeID }
}
/// 侧栏树的可见行投影:节点 + 呈现深度;身份即节点身份(折叠只影响可见集合,
/// 不影响行身份与选中)。
private struct CatalogTreeRow: Identifiable {
let node: CatalogTreeNode
let depth: Int
var id: NodeID { node.id }
}
@@ -0,0 +1,71 @@
import XCTest
@testable import ShadowsocksX_NG2
/// CatalogTreeSnapshot 树投影的纯单元测试:折叠可见行与服务器叶子计数。
/// 直接构造 snapshot(memberwise init),不经工作流。
final class CatalogTreeProjectionTests: XCTestCase {
private func server(_ id: String) -> CatalogTreeNode {
CatalogTreeNode(
id: NodeID(rawValue: id), name: id, isGroup: false, source: .manual, parentID: nil,
invalidReasons: [], childCount: 0, subtreeNodeCount: 0, invalidDescendantCount: 0,
children: nil)
}
private func group(_ id: String, children: [CatalogTreeNode]) -> CatalogTreeNode {
CatalogTreeNode(
id: NodeID(rawValue: id), name: id, isGroup: true, source: .manual, parentID: nil,
invalidReasons: [], childCount: children.count,
subtreeNodeCount: children.reduce(0) { $0 + 1 + $1.subtreeNodeCount },
invalidDescendantCount: 0, children: children)
}
/// 两层嵌套 + 根叶子 + 空组:覆盖深度、子树跳过与空组语义。
private func nestedTree() -> CatalogTreeSnapshot {
CatalogTreeSnapshot(roots: [
server("root-server"),
group(
"outer",
children: [
group("inner", children: [server("deep-leaf")]),
server("outer-leaf"),
]),
group("empty", children: []),
])
}
// MARK: - 可见行折叠投影
func testVisibleRowsWalkDepthFirstWithDepth() {
let rows = nestedTree().visibleRows(collapsed: [])
XCTAssertEqual(
rows.map(\.id.rawValue),
["root-server", "outer", "inner", "deep-leaf", "outer-leaf", "empty"])
XCTAssertEqual(rows.map(\.depth), [0, 0, 1, 2, 1, 0])
XCTAssertEqual(rows.map(\.node.name), rows.map(\.id.rawValue), "行身份即节点身份")
}
func testVisibleRowsSkipCollapsedSubtrees() {
let tree = nestedTree()
XCTAssertEqual(
tree.visibleRows(collapsed: [NodeID(rawValue: "outer")]).map(\.id.rawValue),
["root-server", "outer", "empty"], "收起组的子树不出现,组行自身保留可再展开")
XCTAssertEqual(
tree.visibleRows(collapsed: [NodeID(rawValue: "inner")]).map(\.id.rawValue),
["root-server", "outer", "inner", "outer-leaf", "empty"])
}
func testVisibleRowsTreatUnknownAndLeafIDsAsInert() {
let collapsed: Set<NodeID> = [NodeID(rawValue: "nope"), NodeID(rawValue: "root-server")]
XCTAssertEqual(
nestedTree().visibleRows(collapsed: collapsed).map(\.id.rawValue),
["root-server", "outer", "inner", "deep-leaf", "outer-leaf", "empty"])
}
// MARK: - 服务器叶子计数
func testServerLeafCountCountsLeavesAcrossNesting() {
XCTAssertEqual(nestedTree().serverLeafCount, 3)
XCTAssertEqual(CatalogTreeSnapshot(roots: []).serverLeafCount, 0)
}
}