fix(route): decouple sidebar selection write-back from route publish

The same-value guard only stopped same-value selection re-syncs.
After an external navigation (add menu, rule editor dismissal,
manage-servers/show-runtime actions) the sidebar List still held
its stale cached selection and wrote it back into the custom
selection binding during a view update; the resulting publish
warned and would bounce the navigation back. Route the sidebar
selection through a SwiftUI-managed @State with onChange hops in
both directions (selection -> navigate, route -> backfill), anchor
the seed on WorkspaceRoute.initialDestination, and pin the
alignment with a regression test.
This commit is contained in:
Qiu Yuzhou
2026-10-03 03:38:07 +08:00
parent 9530af2a19
commit 619121a36c
3 changed files with 34 additions and 14 deletions
+19 -10
View File
@@ -44,6 +44,13 @@ struct MainWindowView: View {
let configurationGroupFileExporter: any ConfigurationGroupFileExporter
@StateObject private var homeServerList = HomeServerListState()
/// 侧栏选中项中转:macOS 的 List/NavigationSplitView 会在视图更新期间回写
/// selection(选择再同步,且可能携带缓存的旧值),回写必须落在 SwiftUI 自管
/// 的 @State 上,再经 onChange 单向驱动 route;直接 publish 到 route 会触发
/// view-update 期间发布警告,且缓存的旧值会把外部导航弹回。route 的外部
/// 导航(添加菜单、规则编辑器返回等)经 sidebar 上的 onChange 回填选中项。
/// 初值须与 WorkspaceRoute 的初始 destination 对齐,否则首帧侧栏无高亮行。
@State private var sidebarSelection: WorkspaceDestination? = WorkspaceRoute.initialDestination
@State private var selection: NodeID?
@State private var ruleDraft: CustomRuleDraft?
/// 全局添加菜单打开的表单,以及诊断导出由窗口壳持有。
@@ -271,7 +278,7 @@ extension MainWindowView {
// MARK: - 侧栏
private var sidebar: some View {
List(selection: navigationBinding) {
List(selection: $sidebarSelection) {
Section {
ForEach(WorkspaceDestination.allCases) { destination in
sidebarRow(destination)
@@ -280,6 +287,17 @@ extension MainWindowView {
}
}
.listStyle(.sidebar)
// 点击行 → @State 选中项 → route;nil 回写无对应 destination,忽略。
.onChange(of: sidebarSelection) { _, destination in
guard let destination else { return }
route.navigate(to: destination)
}
// route 的外部导航 → 回填选中项;等值不写,避免导航回声。
.onChange(of: route.destination) { _, destination in
if sidebarSelection != destination {
sidebarSelection = destination
}
}
.safeAreaInset(edge: .top, spacing: 4) { identityHeader }
.safeAreaInset(edge: .bottom, spacing: 4) { statusCard }
}
@@ -319,15 +337,6 @@ extension MainWindowView {
}
}
private var navigationBinding: Binding<WorkspaceDestination?> {
Binding(
get: { route.destination },
set: { destination in
guard let destination else { return }
route.navigate(to: destination)
})
}
/// 行尾数量角标:服务器 = 目录树内服务器叶子数;订阅 = 订阅数。
private func badgeCount(for destination: WorkspaceDestination) -> Int? {
switch destination {
+9 -4
View File
@@ -25,12 +25,17 @@ final class WorkspaceRoute: ObservableObject {
/// scene id 散落(架构测试锚定)。
static let workspaceSceneID = "workspace"
@Published private(set) var destination: WorkspaceDestination = .home
/// 实例初始 destination。MainWindowView 侧栏 @State 选中项的初值须与此
/// 对齐,否则首帧侧栏无高亮行。
static let initialDestination: WorkspaceDestination = .home
@Published private(set) var destination: WorkspaceDestination = initialDestination
/// workspace 内部导航只改变 route destination;开窗不由 route 驱动。
/// 同值导航必须是无操作:macOS 的 List/NavigationSplitView 会在视图更新
/// 期间回写 selection Binding(选择再同步),此时 publish 会触发
/// "Publishing changes from within view updates" 运行时警告。
/// 同值导航必须是无操作:外部导航经 onChange 回填侧栏选中项后会再触发
/// 一次 selection onChange(导航回声),由同值短路变成无操作;publish
/// 一旦发生在视图更新期间(List/NavigationSplitView 的选择再同步回写)
/// 会触发 "Publishing changes from within view updates" 运行时警告。
func navigate(to destination: WorkspaceDestination) {
guard destination != self.destination else { return }
self.destination = destination
@@ -10,6 +10,12 @@ final class WorkspaceRouteTests: XCTestCase {
XCTAssertEqual(WorkspaceRoute().destination, .home)
}
/// 侧栏 @State 选中项的初值锚定在 initialDestination 上(见 MainWindowView);
/// 两者脱钩会导致首帧侧栏无高亮行。
func testInitialDestinationConstantMatchesFreshRoute() {
XCTAssertEqual(WorkspaceRoute.initialDestination, WorkspaceRoute().destination)
}
/// 侧栏 List/NavigationSplitView 会在视图更新期间回写 selection Binding;
/// 同值导航不得 publish(否则触发 view-update 期间发布警告)。
func testNavigateToCurrentDestinationDoesNotPublish() {