diff --git a/ShadowsocksX-NG2/App/CatalogExpansionState.swift b/ShadowsocksX-NG2/App/CatalogExpansionState.swift new file mode 100644 index 00000000..228082d9 --- /dev/null +++ b/ShadowsocksX-NG2/App/CatalogExpansionState.swift @@ -0,0 +1,22 @@ +import Combine + +/// 目录树折叠状态(纯窗口呈现状态):组合根持有的长寿命对象。destination 切换 +/// 会销毁分区子树里的 `@State`,折叠状态因此必须活在视图层级之外;服务器分区 +/// 侧栏与首页目标树共用同一实例,一处收起、两处互见。语义=收起的分组身份集合, +/// 缺省全部展开;不落盘,进程重启回全展开基线。 +@MainActor +final class CatalogExpansionState: ObservableObject { + @Published private(set) var collapsedGroupIDs: Set = [] + + func isCollapsed(_ id: NodeID) -> Bool { + collapsedGroupIDs.contains(id) + } + + func toggleCollapsed(_ id: NodeID) { + if collapsedGroupIDs.contains(id) { + collapsedGroupIDs.remove(id) + } else { + collapsedGroupIDs.insert(id) + } + } +} diff --git a/ShadowsocksX-NG2/App/HomeView+Cards.swift b/ShadowsocksX-NG2/App/HomeView+Cards.swift index 97993f13..40c0fba0 100644 --- a/ShadowsocksX-NG2/App/HomeView+Cards.swift +++ b/ShadowsocksX-NG2/App/HomeView+Cards.swift @@ -4,12 +4,11 @@ import SwiftUI struct TargetTreeCard: View { @ObservedObject var workflow: CatalogWorkflow @ObservedObject var control: ProxyControlWorkflow + /// 分组折叠状态:组合根持有的共享对象,与服务器分区侧栏同源,跨分区切换存续。 + @ObservedObject var expansion: CatalogExpansionState let onManageServers: () -> Void let errors: ErrorAlertPresenter - /// 收起状态的目标分组(默认全部展开)。 - @State private var collapsedGroups: Set = [] - var body: some View { HomeCard( title: "当前服务器", @@ -45,8 +44,8 @@ struct TargetTreeCard: View { TargetGroupSection( group: group, activeTargetID: control.snapshot.activeTarget?.id, - isCollapsed: isCollapsed(group.id), - onToggle: { toggleCollapsed(group.id) }, + isCollapsed: expansion.isCollapsed(group.id), + onToggle: { expansion.toggleCollapsed(group.id) }, onActivate: activate) } ForEach(rootServers) { server in @@ -69,18 +68,6 @@ struct TargetTreeCard: View { } } - private func toggleCollapsed(_ id: NodeID) { - if isCollapsed(id) { - collapsedGroups.remove(id) - } else { - collapsedGroups.insert(id) - } - } - - private func isCollapsed(_ id: NodeID) -> Bool { - collapsedGroups.contains(id) - } - /// 有服务器叶子的分组(递归收集;每组只呈现直属服务器叶子)。 private var targetGroups: [TargetGroupInfo] { var groups: [TargetGroupInfo] = [] diff --git a/ShadowsocksX-NG2/App/HomeView.swift b/ShadowsocksX-NG2/App/HomeView.swift index 1120cee7..f64e1cce 100644 --- a/ShadowsocksX-NG2/App/HomeView.swift +++ b/ShadowsocksX-NG2/App/HomeView.swift @@ -7,6 +7,7 @@ import SwiftUI struct HomeView: View { @ObservedObject var workflow: CatalogWorkflow @ObservedObject var control: ProxyControlWorkflow + let expansion: CatalogExpansionState let clipboard: any TextClipboard let onManageServers: () -> Void let errors: ErrorAlertPresenter @@ -19,6 +20,7 @@ struct HomeView: View { TargetTreeCard( workflow: workflow, control: control, + expansion: expansion, onManageServers: onManageServers, errors: errors) } diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index bf5e0882..076768de 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -104,6 +104,7 @@ private struct AppComposition { let diagnosticsWorkflow: DiagnosticsWorkflow let workspaceRoute: WorkspaceRoute let silentLaunch: SilentLaunchController + let expansion: CatalogExpansionState let textClipboard: any TextClipboard let workspaceContent: MainWindowView @@ -115,16 +116,8 @@ private struct AppComposition { // access to this same in-process source. let catalogBootstrap = CatalogCommitCoordinator.bootstrap( fileStore: dependencies.catalogFileStore) - let controller = ProxyRuntimeController( - catalogSnapshotReader: catalogBootstrap.catalogSnapshotReader, - activationFileStore: dependencies.activationFileStore, - runtimeFileStore: dependencies.runtimeFileStore, - credentials: dependencies.credentials, - listenRestore: dependencies.listenRestore, - settingsStore: dependencies.settingsStore, - settingsRestore: dependencies.settingsRestore, - agent: dependencies.launchAgent, - systemProxyNetworkChangeMonitor: SystemProxyNetworkChangeMonitor()) + let controller = makeRuntimeController( + dependencies: dependencies, bootstrap: catalogBootstrap) let loginController = LaunchAtLoginController(service: dependencies.loginService) let catalogWorkflow = makeCatalogWorkflow( dependencies: dependencies, @@ -145,6 +138,7 @@ private struct AppComposition { catalogFacts: { catalogWorkflow.diagnosticCatalogFacts }) let workspaceRoute = WorkspaceRoute() let silentLaunch = SilentLaunchController(store: dependencies.silentLaunchStore) + let expansion = CatalogExpansionState() let workspaceContent = MainWindowView( route: workspaceRoute, workflow: catalogWorkflow, @@ -154,6 +148,7 @@ private struct AppComposition { settingsWorkflow: settingsWorkflow, loginController: loginController, silentLaunch: silentLaunch, + expansion: expansion, clipboard: textClipboard, diagnosticReportExporter: dependencies.diagnosticReportExporter, configurationGroupFileExporter: dependencies.configurationGroupFileExporter) @@ -166,10 +161,29 @@ private struct AppComposition { diagnosticsWorkflow: diagnosticsWorkflow, workspaceRoute: workspaceRoute, silentLaunch: silentLaunch, + expansion: expansion, textClipboard: textClipboard, workspaceContent: workspaceContent) } + /// 运行时控制器接线:读侧接目录提交协调器快照,写侧接各持久化 seam 与 + /// 系统服务适配器。 + private static func makeRuntimeController( + dependencies: ApplicationDependencies, + bootstrap: CatalogCommitBootstrap + ) -> ProxyRuntimeController { + ProxyRuntimeController( + catalogSnapshotReader: bootstrap.catalogSnapshotReader, + activationFileStore: dependencies.activationFileStore, + runtimeFileStore: dependencies.runtimeFileStore, + credentials: dependencies.credentials, + listenRestore: dependencies.listenRestore, + settingsStore: dependencies.settingsStore, + settingsRestore: dependencies.settingsRestore, + agent: dependencies.launchAgent, + systemProxyNetworkChangeMonitor: SystemProxyNetworkChangeMonitor()) + } + /// 目录工作流接线(issue #40/#41/#49):提交协调器与生产运行时适配器在此 /// 一次性装配;Legacy 导入后的 2.0 运行时边界同为一次性注入。 private static func makeCatalogWorkflow( diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index b1cc7342..a71b6714 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -35,6 +35,9 @@ struct MainWindowView: View { @ObservedObject var settingsWorkflow: SettingsWorkflow @ObservedObject var loginController: LaunchAtLoginController @ObservedObject var silentLaunch: SilentLaunchController + /// 目录树折叠状态:组合根持有的长寿命对象,跨 destination 切换存续;服务器 + /// 侧栏与首页目标树共用(见 CatalogExpansionState)。 + @ObservedObject var expansion: CatalogExpansionState let clipboard: any TextClipboard let diagnosticReportExporter: any DiagnosticReportExporter let configurationGroupFileExporter: any ConfigurationGroupFileExporter @@ -183,6 +186,7 @@ struct MainWindowView: View { WorkspaceHomeView( workflow: workflow, control: control, + expansion: expansion, clipboard: clipboard, onManageServers: { route.navigate(to: .servers) }) case .servers: @@ -190,6 +194,7 @@ struct MainWindowView: View { workflow: workflow, proxyController: proxyController, selection: $selection, + expansion: expansion, clipboard: clipboard, configurationGroupFileExporter: configurationGroupFileExporter) case .subscriptions: diff --git a/ShadowsocksX-NG2/App/ServersView.swift b/ShadowsocksX-NG2/App/ServersView.swift index 3ccb400d..5067dcff 100644 --- a/ShadowsocksX-NG2/App/ServersView.swift +++ b/ShadowsocksX-NG2/App/ServersView.swift @@ -11,6 +11,9 @@ struct ServersView: View { /// 运行时事实来源:活动目标标记;激活命令经 `workflow.activate`。 @ObservedObject var proxyController: ProxyRuntimeController @Binding var selection: NodeID? + /// 分组折叠状态:组合根持有的共享对象(与首页目标树同源),跨 destination + /// 切换存续;OutlineGroup 的内建展开态会随分区切换丢失,侧栏因此自管折叠。 + @ObservedObject var expansion: CatalogExpansionState let clipboard: any TextClipboard let configurationGroupFileExporter: any ConfigurationGroupFileExporter @@ -96,33 +99,8 @@ struct ServersView: View { private var serverSidebar: some View { List(selection: $selection) { - OutlineGroup(workflow.tree.roots, children: \.children) { node in - SidebarRow( - node: node, - workflow: workflow, - activeTargetID: proxyController.activeTargetID, - errors: errors, - onRename: { id in - renameTarget = id - renameText = workflow.displayName(for: id) - }, - onNewGroup: { parent in - presentNewGroup(in: parent) - }, - onMove: { moveTarget = $0 }, - onDelete: { deleteTarget = $0 }, - onExport: exportConfigurationGroup - ) - .onDrag { - guard let payload = workflow.dragPayload(for: node.id) else { - return NSItemProvider() - } - return NSItemProvider(object: payload as NSString) - } - .dropDestination(for: String.self) { payload, _ in - handleDrop(payload, onto: node.id) - } isTargeted: { _ in - } + ForEach(visibleRows) { row in + treeRow(row) } } .listStyle(.sidebar) @@ -143,6 +121,81 @@ 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) { + if node.isGroup { + if node.childNodes.isEmpty { + Color.clear.frame(width: 11, height: 11) + } else { + Button { + withAnimation { expansion.toggleCollapsed(node.id) } + } label: { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(expansion.isCollapsed(node.id) ? 0 : 90)) + } + .buttonStyle(.plain) + .help(expansion.isCollapsed(node.id) ? "展开" : "收起") + } + } + SidebarRow( + node: node, + workflow: workflow, + activeTargetID: proxyController.activeTargetID, + errors: errors, + onRename: { id in + renameTarget = id + renameText = workflow.displayName(for: id) + }, + onNewGroup: { parent in + presentNewGroup(in: parent) + }, + onMove: { moveTarget = $0 }, + onDelete: { deleteTarget = $0 }, + onExport: exportConfigurationGroup + ) + } + .padding(.leading, leadingInset(for: row)) + .tag(node.id) + .onDrag { + guard let payload = workflow.dragPayload(for: node.id) else { + return NSItemProvider() + } + return NSItemProvider(object: payload as NSString) + } + .dropDestination(for: String.self) { payload, _ in + handleDrop(payload, onto: node.id) + } isTargeted: { _ in + } + } + + /// 层级缩进:分组行按深度缩进;叶子行额外让出箭头槽位与分组文本对齐。 + private func leadingInset(for row: CatalogTreeRow) -> CGFloat { + let depthStep: CGFloat = 14 + let chevronSlot: CGFloat = 15 + if row.node.isGroup { + return CGFloat(row.depth) * depthStep + } + return CGFloat(row.depth) * depthStep + chevronSlot + } + /// 删除结果 → selection invalidation(story 18):仅清除失效选择,不自动 /// 改选相邻节点。 private func clearSelectionIfInvalidated(_ removed: Set) { @@ -309,3 +362,11 @@ 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 } +} diff --git a/ShadowsocksX-NG2/App/WorkspaceDestinations.swift b/ShadowsocksX-NG2/App/WorkspaceDestinations.swift index 5e8c34dc..93ef07fd 100644 --- a/ShadowsocksX-NG2/App/WorkspaceDestinations.swift +++ b/ShadowsocksX-NG2/App/WorkspaceDestinations.swift @@ -6,6 +6,7 @@ import SwiftUI struct WorkspaceHomeView: View { @ObservedObject var workflow: CatalogWorkflow @ObservedObject var control: ProxyControlWorkflow + let expansion: CatalogExpansionState let clipboard: any TextClipboard let onManageServers: () -> Void @StateObject private var errors = ErrorAlertPresenter() @@ -17,6 +18,7 @@ struct WorkspaceHomeView: View { HomeView( workflow: workflow, control: control, + expansion: expansion, clipboard: clipboard, onManageServers: onManageServers, errors: errors diff --git a/ShadowsocksX-NG2/Tests/CatalogExpansionStateTests.swift b/ShadowsocksX-NG2/Tests/CatalogExpansionStateTests.swift new file mode 100644 index 00000000..06d6c495 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/CatalogExpansionStateTests.swift @@ -0,0 +1,43 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +@MainActor +final class CatalogExpansionStateTests: XCTestCase { + func testFreshStateIsFullyExpanded() { + let expansion = CatalogExpansionState() + + XCTAssertTrue(expansion.collapsedGroupIDs.isEmpty) + XCTAssertFalse(expansion.isCollapsed(NodeID(rawValue: "g1"))) + } + + /// 切换语义:未收起 → 收起 → 再切换 → 展开(分区切换往返不重置的事实基础)。 + func testToggleCollapsesThenReexpands() { + let expansion = CatalogExpansionState() + let id = NodeID(rawValue: "g1") + + expansion.toggleCollapsed(id) + XCTAssertTrue(expansion.isCollapsed(id)) + + expansion.toggleCollapsed(id) + XCTAssertFalse(expansion.isCollapsed(id)) + } + + /// 两处服务器树(服务器侧栏/首页目标树)共用同一实例:同一分组身份在两处 + /// 读到同一折叠事实,互不覆盖。 + func testCollapseSetIsKeyedPerGroupID() { + let expansion = CatalogExpansionState() + let first = NodeID(rawValue: "g-first") + let second = NodeID(rawValue: "g-second") + + expansion.toggleCollapsed(first) + expansion.toggleCollapsed(second) + + XCTAssertEqual(expansion.collapsedGroupIDs, [first, second]) + + expansion.toggleCollapsed(first) + XCTAssertEqual(expansion.collapsedGroupIDs, [second]) + XCTAssertFalse(expansion.isCollapsed(first)) + XCTAssertTrue(expansion.isCollapsed(second)) + } +}