mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-01 07:33:46 +00:00
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:
@@ -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,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] = []
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user