mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-31 23:03:43 +00:00
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.
This commit is contained in:
@@ -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<RulesSourceChoice?> {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<RulesRow.SelectionID>) {
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user