Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/UI/Rules/CustomRuleEditorSheet.swift
T
Qiu Yuzhou fb428f12bc refactor(app): 按分层重组 App/ 源码目录
App/ 根目录原有 73 个散落源文件,已无法从目录读出任何分组信息。

按「目录即分层」重组为 Composition / Application / Presentation / UI /
PlatformEffects / Resources 六个目录,UI 层再按子视图分目录(Workspace、
Home、Servers(含 Plugin)、Subscriptions、Rules、Settings、Diagnostics、
StatusMenu)。纯移动:类型名、模块名与 import 均未改动,App/ 根层不再有
散落文件。

配套改动:

- project.yml:Core 测试镜像的 excludes 由裸文件名改为 `**/` 递归形式。
  XcodeGen 的 exclude 不跨 `/`,裸名只匹配 App 根层;MainApp.swift 下移后
  旧写法会静默失效,把 @main 组合根编进测试镜像。info.path 与
  entitlements.path 指向 App/Resources/;并排除 `**/*.md`——App/ 下任何
  非源码文件都会被 XcodeGen 收进 Resources 阶段并封进签名后的 bundle。
- SkeletonTests 的架构守卫由「默认扫描全部 App/ + 7 个豁免文件 + 1 个豁免
  前缀 + 4 个豁免目录」改为正面圈定 App/UI/,豁免登记全部删除;新增运行时
  适配器放进 Application/ 即自动落在扫描范围外,无需再登记。
- README 工程结构说明同步;根 .gitignore 的生成物路径随 Resources/ 归位。

验证:clean build 0 error;task test 1022 用例全通过(含两个架构守卫);
task format 无改动(证明纯移动);task lint 与移动前同为 21 条内容型
warning;packaging-gate.sh ALL CHECKS PASSED,确认 plist/entitlements 归位
后签名与 entitlements 语义不变。

注意:迁移时须先删旧的生成物再 xcodegen generate,否则遗留的 App/Info.plist
会被当作普通资源扫进 Resources 阶段。
2026-10-05 19:56:18 +08:00

165 lines
6.7 KiB
Swift

import SwiftUI
/// The draft belongs to this presentation; only an explicit save enters the Workflow.
struct CustomRuleEditorSheet: View {
@ObservedObject var workflow: RulesWorkflow
@Environment(\.dismiss) private var dismiss
@State private var draft: CustomRuleDraft
@State private var preview: CustomRulePreview?
@State private var isSaving = false
@State private var saveFailure: CustomRuleUpdateOutcome?
init(workflow: RulesWorkflow, draft: CustomRuleDraft) {
self.workflow = workflow
_draft = State(initialValue: draft)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(RulesCopy.text(draft.editingID == nil ? "新增规则" : "编辑规则"))
.font(.title2)
Form {
VStack(alignment: .leading, spacing: 10) {
Picker(RulesCopy.text("类型"), selection: $draft.kind) {
ForEach(CustomRuleDraft.Kind.allCases, id: \.self) { kind in
Text(verbatim: kind.label).tag(kind)
}
}
.pickerStyle(.segmented)
TextField(RulesCopy.text("匹配内容"), text: $draft.content)
.autocorrectionDisabled()
Picker(RulesCopy.text("行动"), selection: $draft.action) {
Text(RulesCopy.text("代理")).tag(RuleAction.proxy)
Text(RulesCopy.text("直连")).tag(RuleAction.direct)
}
.pickerStyle(.segmented)
}
}.disabled(isSaving || workflow.snapshot.isCommitting)
ScrollView {
VStack(alignment: .leading, spacing: 10) {
if let preview {
if let content = preview.displayContent {
LabeledContent(RulesCopy.text("规范化预览")) {
Text(verbatim: content).textSelection(.enabled)
}
}
if let failure = preview.failure {
Text(verbatim: failure.message).foregroundStyle(.orange)
} else if let row = preview.row {
if !row.sources.subtracting([.custom]).isEmpty {
Text(RulesCopy.text("将与已有来源合并显示。"))
Text(verbatim: row.sourceLabels).foregroundStyle(.secondary)
}
if preview.inheritsDisablement {
Text(RulesCopy.text("新身份将继承禁用状态;旧禁用记录保留。"))
} else if !row.isEnabled {
Text(RulesCopy.text("此身份已禁用;保存不会重新启用。"))
}
RulesRelationshipsContent(row: row)
Text(RulesCopy.text("覆盖关系仅解释已启用规则之间的关系,不代表当前流量行为。"))
.font(.caption).foregroundStyle(.secondary)
}
} else {
ProgressView().controlSize(.small)
}
if let saveFailure {
Label(saveFailure.rulesMessage, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
if let detail = saveFailure.failureDetail {
Text(verbatim: detail).textSelection(.enabled)
}
if let nextStep = saveFailure.nextStep { Text(RulesCopy.text(nextStep)) }
}
}.frame(maxWidth: .infinity, alignment: .leading)
}
// 预览区随内容伸缩,上限封顶,避免初始 ProgressView 阶段撑出大片空白、
// 长预览把 sheet 无限拉高。
.frame(maxHeight: 210)
HStack {
if isSaving || workflow.snapshot.isCommitting {
ProgressView().controlSize(.small)
Text(RulesCopy.text("正在更新规则…")).font(.caption)
}
Spacer()
Button("取消", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
.disabled(isSaving || workflow.snapshot.isCommitting)
Button("保存") { save() }
.keyboardShortcut(.defaultAction)
.disabled(preview?.document == nil || isSaving || !workflow.snapshot.isComplete)
}
}
.onChange(of: draft) { _, _ in saveFailure = nil }
// 预览内容高度天然波动(失败提示一两行,合法预览可达上限),尺寸变化
// 只发生在合法↔失败、关系行增减这类语义边界;平缓过渡避免 sheet 跳动。
.animation(.snappy(duration: 0.2), value: preview)
.animation(.snappy(duration: 0.2), value: saveFailure)
.padding(20)
.frame(width: 530)
.interactiveDismissDisabled(isSaving || workflow.snapshot.isCommitting)
.task(
id: PreviewRequest(
draft: draft, version: workflow.snapshot.version, isComplete: workflow.snapshot.isComplete)
) {
// 重算期间保留上一次预览:清空会让预览区塌缩成进度条,sheet 随
// 每个按键先缩后胀;新结果就绪后原位替换(旧任务由 task(id:) 取消)。
let result = await workflow.previewCustomRule(draft)
guard !Task.isCancelled else { return }
preview = result
}
}
private func save() {
guard !isSaving, workflow.snapshot.isComplete else { return }
isSaving = true
saveFailure = nil
Task {
let result = await workflow.saveCustomRule(draft)
isSaving = false
switch result {
case .unavailable(let failure): preview = CustomRulePreview(failure: failure)
case .committed(let outcome):
if outcome.isSuccess { dismiss() } else { saveFailure = outcome }
}
}
}
private struct PreviewRequest: Equatable {
let draft: CustomRuleDraft
let version: String
let isComplete: Bool
}
}
extension CustomRuleDraft.Kind {
var label: String {
switch self {
case .ipAddress: RulesCopy.text("IP 地址")
case .cidr: RulesCopy.text("CIDR 范围")
case .domainSuffix: RulesCopy.text("域名后缀")
case .domainExact: RulesCopy.text("精确域名")
}
}
}
extension CustomRulePreview.Failure {
var message: String {
switch self {
case .incompleteCollection: RulesCopy.text("集合不完整")
case .busy: RulesCopy.text("正在更新规则…")
case .staleDraft: RulesCopy.text("规则集合已变化,请重新打开编辑器。")
case .duplicate: RulesCopy.text("相同匹配条件和行动的自定义规则已存在。")
case .fixedPolicyDisablement:
RulesCopy.text("无法更新规则") + " · " + RulesCopy.text("固定策略优先,以下范围始终直连。")
case .fixedLocalConflict: RulesCopy.text("代理规则与固定本地策略冲突,无法保存。")
case .invalidInput(let kind):
switch kind {
case .ipAddress: RulesCopy.text("请输入有效的 IPv4 或 IPv6 地址。")
case .cidr: RulesCopy.text("请输入有效的 IPv4 或 IPv6 CIDR 范围。")
case .domainExact, .domainSuffix:
RulesCopy.text("请输入有效的 ASCII 域名;不支持通配符、URL 或路径。")
}
}
}
}