From b8958957f0f35ae9852abdebee7815cf80802aa6 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Fri, 2 Oct 2026 18:10:00 +0800 Subject: [PATCH] fix(rules): stabilize report expansion and window layout Bound source metadata and conversion reports to a scrolling region so expansion does not compete with the native table for window constraints. Replace the nested split with a fixed source list and flexible table to keep the rules page inside the main window. Defer native binding writes until after the current view update, merge filter edits into the latest query, and suppress unchanged query and selection publications. Validated with 744 hostless tests, an unsigned Debug build, and isolated native window checks at 920x738 and 920x580, including real source selection and disclosure clicks. --- ShadowsocksX-NG2/App/RulesView.swift | 37 +++++++++++++------ ShadowsocksX-NG2/App/RulesWorkflow.swift | 5 ++- .../Tests/RulesWorkflowPublicationTests.swift | 28 ++++++++++++++ 3 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 ShadowsocksX-NG2/Tests/RulesWorkflowPublicationTests.swift diff --git a/ShadowsocksX-NG2/App/RulesView.swift b/ShadowsocksX-NG2/App/RulesView.swift index 951d7263..34f90cf6 100644 --- a/ShadowsocksX-NG2/App/RulesView.swift +++ b/ShadowsocksX-NG2/App/RulesView.swift @@ -21,14 +21,18 @@ struct RulesView: View { } .frame(maxWidth: .infinity, alignment: .leading).padding() } - HSplitView { - sourceList.frame(minWidth: 160, idealWidth: 180, maxWidth: 240) + HStack(spacing: 0) { + sourceList.frame(width: 180) + Divider() VStack(spacing: 0) { filters.padding() if let source = workflow.snapshot.sources.first(where: { $0.id == workflow.snapshot.query.source }) { - RulesSourceDetailsView(source: source).padding(.horizontal) + ScrollView { + RulesSourceDetailsView(source: source) + .frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal) + }.frame(height: 160) } ruleTable if workflow.snapshot.rows.isEmpty && !workflow.snapshot.isLoading { @@ -42,7 +46,7 @@ struct RulesView: View { RulesDetailsView(row: row, sources: workflow.snapshot.sources) .frame(minHeight: 110, idealHeight: 180, maxHeight: 260) } - }.frame(minWidth: 500) + }.frame(minWidth: 400, maxWidth: .infinity) } } .task { @@ -102,15 +106,20 @@ struct RulesView: View { Table( workflow.snapshot.rows, selection: Binding( - get: { workflow.snapshot.selection }, set: { workflow.select($0) }) + get: { workflow.snapshot.selection }, + set: { ids in DispatchQueue.main.async { workflow.select(ids) } }) ) { TableColumn(RulesCopy.text("匹配内容")) { row in Text(verbatim: row.displayContent) } - .width(min: 170, ideal: 260) + .width(min: 120, ideal: 170) TableColumn(RulesCopy.text("类型")) { row in Text(verbatim: row.matchType) } + .width(min: 70, ideal: 80) TableColumn(RulesCopy.text("行动")) { row in Text(row.actionLabel) } + .width(50) TableColumn(RulesCopy.text("来源")) { row in Text(row.sourceLabels) } + .width(min: 75, ideal: 90) TableColumn(RulesCopy.text("状态")) { row in Text(row.statusLabel) } - } + .width(min: 80, ideal: 110) + }.frame(minWidth: 0, maxWidth: .infinity) } private var sourceBinding: Binding { @@ -119,16 +128,22 @@ struct RulesView: View { workflow.snapshot.query.source.map(RulesSourceChoice.source) ?? .all }, set: { choice in + guard let choice else { return } updateQuery { if case .source(let source) = choice { $0.source = source } else { $0.source = nil } } }) } - private func updateQuery(_ update: (inout RulesQuery) -> Void) { - var query = workflow.snapshot.query - update(&query) - workflow.query(query) + // Native controls can write their bindings during a SwiftUI update. Publish + // after that pass, and merge each edit into the latest query so queued edits + // to different filters do not overwrite one another. + private func updateQuery(_ update: @escaping @MainActor (inout RulesQuery) -> Void) { + DispatchQueue.main.async { + var query = workflow.snapshot.query + update(&query) + workflow.query(query) + } } private func issueDescription(_ issue: RulesPageSnapshot.Issue) -> String { diff --git a/ShadowsocksX-NG2/App/RulesWorkflow.swift b/ShadowsocksX-NG2/App/RulesWorkflow.swift index 38f01194..8b611de3 100644 --- a/ShadowsocksX-NG2/App/RulesWorkflow.swift +++ b/ShadowsocksX-NG2/App/RulesWorkflow.swift @@ -46,13 +46,16 @@ final class RulesWorkflow: ObservableObject { } func query(_ query: RulesQuery) { + guard query != snapshot.query else { return } var next = snapshot next.query = query snapshot = applyingQuery(to: next) } func select(_ ids: Set) { - snapshot.selection = ids.intersection(Set(snapshot.rows.map(\.id))) + let selection = ids.intersection(Set(snapshot.rows.map(\.id))) + guard selection != snapshot.selection else { return } + snapshot.selection = selection } private func applyingQuery(to page: RulesPageSnapshot) -> RulesPageSnapshot { diff --git a/ShadowsocksX-NG2/Tests/RulesWorkflowPublicationTests.swift b/ShadowsocksX-NG2/Tests/RulesWorkflowPublicationTests.swift new file mode 100644 index 00000000..89fd6f01 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/RulesWorkflowPublicationTests.swift @@ -0,0 +1,28 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +@MainActor +final class RulesWorkflowPublicationTests: XCTestCase { + func testNativeControlsWritingTheSameQueryDoNotRepublishThePage() async { + let workflow = RulesWorkflow(loadCustom: { [] }, loadBuiltin: { rulesFixture($0) }) + await workflow.refresh() + workflow.query(RulesQuery(source: .gfwlist)) + var changes = 0 + let observation = workflow.$snapshot.dropFirst().sink { _ in changes += 1 } + workflow.query(workflow.snapshot.query) + observation.cancel() + XCTAssertEqual(changes, 0) + } + + func testNativeControlsWritingTheSameVisibleSelectionDoNotRepublishThePage() async { + let workflow = RulesWorkflow(loadCustom: { [] }, loadBuiltin: { rulesFixture($0) }) + await workflow.refresh() + workflow.select([.noDotHostname]) + var changes = 0 + let observation = workflow.$snapshot.dropFirst().sink { _ in changes += 1 } + workflow.select([.noDotHostname]) + observation.cancel() + XCTAssertEqual(changes, 0) + } +}