fix(app): keep server tree collapse state across destinations

Both server trees held collapse state inside the destination
subtree — ServersView relied on OutlineGroup's built-in expansion
state and the home target tree kept a local @State set — so every
destination switch destroyed it. Hoist a shared
CatalogExpansionState to the composition root; the servers sidebar
and the home target tree now read and write one collapse set.

ServersView replaces OutlineGroup with a manual collapse tree
(flattened visible rows, .tag keeps List selection), trading away
OutlineGroup's arrow-key expand/collapse. MainApp extracts
makeRuntimeController to stay within the function-body lint budget.
This commit is contained in:
Qiu Yuzhou
2026-09-30 21:50:09 +08:00
parent ba07b38134
commit 2f8c4dbc2a
8 changed files with 190 additions and 54 deletions
@@ -0,0 +1,22 @@
import Combine
/// 目录树折叠状态(纯窗口呈现状态):组合根持有的长寿命对象。destination 切换
/// 会销毁分区子树里的 `@State`,折叠状态因此必须活在视图层级之外;服务器分区
/// 侧栏与首页目标树共用同一实例,一处收起、两处互见。语义=收起的分组身份集合,
/// 缺省全部展开;不落盘,进程重启回全展开基线。
@MainActor
final class CatalogExpansionState: ObservableObject {
@Published private(set) var collapsedGroupIDs: Set<NodeID> = []
func isCollapsed(_ id: NodeID) -> Bool {
collapsedGroupIDs.contains(id)
}
func toggleCollapsed(_ id: NodeID) {
if collapsedGroupIDs.contains(id) {
collapsedGroupIDs.remove(id)
} else {
collapsedGroupIDs.insert(id)
}
}
}
+4 -17
View File
@@ -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<NodeID> = []
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] = []
+2
View File
@@ -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)
}
+24 -10
View File
@@ -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(
@@ -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:
+88 -27
View File
@@ -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<NodeID>) {
@@ -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 }
}
@@ -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
@@ -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))
}
}