From bdcbc6cd60b0bf6157d473794ad281ea016883e2 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Tue, 6 Oct 2026 12:25:13 +0800 Subject: [PATCH] feat(servers): separate details from edit forms --- .../CatalogWorkflow/CatalogWorkflow.swift | 27 ++ .../CatalogWorkflowModels.swift | 9 + .../PluginAvailabilityPresentation.swift | 15 + .../App/UI/Servers/NewServerSheet.swift | 69 ----- .../Servers/Plugin/ServerPluginSection.swift | 21 +- .../UI/Servers/ServerDetailParameters.swift | 30 ++ .../App/UI/Servers/ServerDetailView.swift | 272 ++++++------------ .../App/UI/Servers/ServerFormFields.swift | 9 +- .../App/UI/Servers/ServerFormSheet.swift | 156 ++++++++++ .../App/UI/Servers/ServerSidebarRow.swift | 3 + .../App/UI/Servers/ServersView.swift | 46 +-- .../Servers/ServerFormLoadingTests.swift | 153 ++++++++++ ...-detail-reads-plugin-options-separately.md | 9 + .../server-read-only-detail-and-edit-sheet.md | 33 +++ 14 files changed, 570 insertions(+), 282 deletions(-) create mode 100644 ShadowsocksX-NG2/App/Presentation/PluginAvailabilityPresentation.swift delete mode 100644 ShadowsocksX-NG2/App/UI/Servers/NewServerSheet.swift create mode 100644 ShadowsocksX-NG2/App/UI/Servers/ServerDetailParameters.swift create mode 100644 ShadowsocksX-NG2/App/UI/Servers/ServerFormSheet.swift create mode 100644 docs/adr/0030-server-detail-reads-plugin-options-separately.md create mode 100644 docs/design/server-read-only-detail-and-edit-sheet.md diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift index 03e878ce..a1e50d66 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift @@ -69,6 +69,13 @@ final class CatalogWorkflow: ObservableObject { subscriptionServerRefreshSubject.eraseToAnyPublisher() } + private let serverUpdateSubject = PassthroughSubject, Never>() + + /// 成功保存或订阅刷新后装载详情参数,包括只改变凭据而树值不变的提交。 + var serverDetailChanges: AnyPublisher, Never> { + subscriptionServerRefreshSubject.merge(with: serverUpdateSubject).eraseToAnyPublisher() + } + func publishSubscriptionServerRefresh(_ id: NodeID) { guard let summary = subscriptions.first(where: { $0.id == id }), let group = tree.node(withID: summary.groupID) @@ -97,6 +104,25 @@ final class CatalogWorkflow: ObservableObject { isEditable: entry.source == .manual, plugin: pluginPresentation(for: fields)) } + /// 已保存详情事实;普通渲染不读取密码或参数。 + func serverDetailPresentation(for id: NodeID) -> ServerDetailPresentation? { + guard let entry = dependencies.coordinator.committedCatalog.entry(for: id), + case .server(let fields) = entry.kind + else { return nil } + return ServerDetailPresentation( + name: entry.displayName, address: fields.address, port: fields.port, + encryptionMethod: fields.encryptionMethod, plugin: pluginPresentation(for: fields)) + } + + /// 详情参数装载独立于密码和插件可用性;未知插件仍可核对原文。 + func serverDetailPluginOptions(for id: NodeID) throws -> String { + guard let entry = dependencies.coordinator.committedCatalog.entry(for: id), + case .server(let fields) = entry.kind + else { throw ServerFormLoadError.notFound } + guard let reference = fields.pluginOptionsRef else { return "" } + return try requiredServerSecret(reference) + } + /// 服务器编辑面(显式命令,story 11):解析密码与目录中的具名插件参数明文。 /// 节点不存在或不是服务器叶子为 `nil`。 func serverEditForm(for id: NodeID) throws -> ServerEditForm? { @@ -273,6 +299,7 @@ final class CatalogWorkflow: ObservableObject { credentials: dependencies.credentials, journal: &journal) try catalog.updateServer(id, with: fields) } + serverUpdateSubject.send([id]) } catch { throw CommitError(underlying: error, credentialRollback: journal.rollback()) } diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift index c181804d..baaae2c1 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift @@ -412,3 +412,12 @@ struct ServerFormPresentation: Equatable { self.plugin = facts } } + +/// 详情的已保存展示事实,参数明文通过独立读取 interface 装载。 +struct ServerDetailPresentation { + let name: String + let address: String + let port: Int + let encryptionMethod: String + let plugin: PluginSectionState +} diff --git a/ShadowsocksX-NG2/App/Presentation/PluginAvailabilityPresentation.swift b/ShadowsocksX-NG2/App/Presentation/PluginAvailabilityPresentation.swift new file mode 100644 index 00000000..e67ae4fa --- /dev/null +++ b/ShadowsocksX-NG2/App/Presentation/PluginAvailabilityPresentation.swift @@ -0,0 +1,15 @@ +extension PluginSectionState { + /// 详情和编辑表单共用可用性解释;未知引用的操作建议由各视图呈现。 + func availabilityWarning(for program: String) -> String? { + if mappingsUnreadable { + return "无法读取用户插件映射,插件暂不可用。请修复映射后重试。" + } + guard let facts = programs.first(where: { $0.program == program }) else { return nil } + switch facts.availability { + case .available: return nil + case .missing: return "插件文件不存在,请检查用户插件路径或应用安装。" + case .notExecutable: return "插件文件不可执行,请检查文件类型和执行权限。" + case .unreadable: return "无法读取插件文件信息,请检查路径和访问权限。" + } + } +} diff --git a/ShadowsocksX-NG2/App/UI/Servers/NewServerSheet.swift b/ShadowsocksX-NG2/App/UI/Servers/NewServerSheet.swift deleted file mode 100644 index 43a6b523..00000000 --- a/ShadowsocksX-NG2/App/UI/Servers/NewServerSheet.swift +++ /dev/null @@ -1,69 +0,0 @@ -import SwiftUI - -/// 「新建服务器」表单(服务器视图工具栏入口):连接字段草稿与表单栅格走 -/// 共享 `ServerFormFields`(与编辑表单同一 interface),默认值端口 8388、 -/// 加密 aes-256-gcm、插件「无」。落点由父视图按既有 -/// `importTargetParent` 语义在打开时固定;提交成功关闭表单并把新身份交给 -/// selection(由父视图选中);校验/提交失败留在表单,错误经共享呈现器点名。 -struct NewServerSheet: View { - let workflow: CatalogWorkflow - let errors: ErrorAlertPresenter - let parent: NodeID? - let onCreated: (NodeID) -> Void - @Environment(\.dismiss) private var dismiss - - /// 连接字段草稿由共享 module 持有(与编辑表单同一 interface),默认值 - /// 端口 8388、加密 aes-256-gcm、插件「无」。 - @StateObject private var fields = ServerFormFields.newForm() - @State private var isSubmitting = false - @FocusState private var fieldFocus: ServerFormField? - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Text("新建服务器") - .font(.title2) - - // isEditable 恒真:提交期间的禁用由外层 .disabled(isSubmitting) 负责, - // 避免参数编辑区在提交瞬间闪换成订阅只读视图。 - ServerFormFieldsGrid( - fields: fields, plugin: pluginSection, isEditable: true, - fieldFocus: $fieldFocus) - - HStack { - Spacer() - Button("取消", role: .cancel) { dismiss() } - Button("创建") { create() } - .keyboardShortcut(.defaultAction) - } - } - .disabled(isSubmitting) - .interactiveDismissDisabled(isSubmitting) - .defaultFocus($fieldFocus, .name) - .padding(20) - .frame(minWidth: 520, minHeight: 400) - } - - /// 插件区状态经 workflow 投影(与编辑面同缝),选中态跟随草稿。 - private var pluginSection: PluginSectionState { - workflow.newFormPluginSection(selection: fields.pluginChoice) - } - - private func create() { - guard !isSubmitting else { return } - guard fields.validateForSubmit(), let draft = fields.draft else { - fieldFocus = fields.firstErrorField - return - } - isSubmitting = true - Task { - defer { isSubmitting = false } - do { - let id = try await workflow.createServer(draft, into: parent) - onCreated(id) - dismiss() - } catch { - errors.present(error) - } - } - } -} diff --git a/ShadowsocksX-NG2/App/UI/Servers/Plugin/ServerPluginSection.swift b/ShadowsocksX-NG2/App/UI/Servers/Plugin/ServerPluginSection.swift index 00dcd9e6..5b7e2e86 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/Plugin/ServerPluginSection.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/Plugin/ServerPluginSection.swift @@ -23,7 +23,9 @@ struct ServerPluginSection: View { Picker("插件", selection: $selection) { Text("无").tag(PluginSelection.none) ForEach(plugin.programs.map(\.program), id: \.self) { program in - Text(program).tag(PluginSelection.named(program: program)) + Text(program).tag( + selection == .unknown(program: program) + ? selection : PluginSelection.named(program: program)) } if let program = plugin.unresolvedProgram(for: selection) { Text(program).tag(selection) @@ -96,19 +98,8 @@ struct ServerPluginSection: View { @ViewBuilder private func availabilityNotice(program: String, plugin: PluginSectionState) -> some View { - if plugin.mappingsUnreadable { - warning(Text("无法读取用户插件映射,插件暂不可用。请修复映射后重试。")) - } else if let facts = plugin.programs.first(where: { $0.program == program }) { - switch facts.availability { - case .available: - EmptyView() - case .missing: - warning(Text("插件文件不存在,请检查用户插件路径或应用安装。")) - case .notExecutable: - warning(Text("插件文件不可执行,请检查文件类型和执行权限。")) - case .unreadable: - warning(Text("无法读取插件文件信息,请检查路径和访问权限。")) - } + if let message = plugin.availabilityWarning(for: program) { + warning(Text(message)) } } @@ -335,7 +326,7 @@ private struct PluginOptionsEditor: View { /// 订阅节点的参数只读呈现(issue #81):Table 控件的表格展现与原始文本 /// 只读,可切换查看模式,无任何编辑入口。 -private struct PluginOptionsReadOnlyEditor: View { +struct PluginOptionsReadOnlyEditor: View { @ObservedObject var draft: PluginOptionsDraft private var visibleRows: [PluginOptionsDraft.Row] { diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServerDetailParameters.swift b/ShadowsocksX-NG2/App/UI/Servers/ServerDetailParameters.swift new file mode 100644 index 00000000..7f0c374d --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Servers/ServerDetailParameters.swift @@ -0,0 +1,30 @@ +import Combine + +/// 只读详情的参数会话:切换身份清空旧值,刷新失败不展示过时参数。 +@MainActor +final class ServerDetailParameters: ObservableObject { + let options = PluginOptionsDraft() + @Published private(set) var failure: ServerFormLoadError? + private var serverID: NodeID? + + func showServer(_ id: NodeID, load: (NodeID) throws -> String) { + serverID = id + reload(load: load) + } + + func reload(load: (NodeID) throws -> String) { + guard let serverID else { return } + options.load("") + do { + options.load(try load(serverID)) + failure = nil + } catch { + failure = (error as? ServerFormLoadError) ?? .credentialsUnavailable + } + } + + func didRefresh(affectedServers: Set, load: (NodeID) throws -> String) { + guard let serverID, affectedServers.contains(serverID) else { return } + reload(load: load) + } +} diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServerDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/ServerDetailView.swift index e3fe36e2..bbb56ff0 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ServerDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ServerDetailView.swift @@ -1,202 +1,116 @@ import SwiftUI -/// 服务器详情:标题反映已保存名称,字段持有独立草稿;订阅服务器只读。 -/// 保存成功后重载提交值,失败保留草稿;分享经分区工具栏命令读取已保存 -/// 配置(ServersView+Share.swift)。 +/// 已保存服务器资料;详情只装载插件参数,不读取密码或编辑草稿。 struct ServerDetailView: View { @ObservedObject var workflow: CatalogWorkflow let serverID: NodeID - /// 运行时事实(活动目标标记):由父视图从既有接缝传入,详情面不持控制器。 let isActiveTarget: Bool - let errors: ErrorAlertPresenter + @StateObject private var parameters = ServerDetailParameters() - /// 连接字段草稿由共享 module 持有(与新建表单同一 interface),经显式 - /// 编辑命令装载(凭据明文仅在编辑动作中出现)。 - @StateObject private var fields = ServerFormFields() - @State private var isSubmitting = false - @FocusState private var fieldFocus: ServerFormField? - - private var isEditable: Bool { - fields.presentation?.isEditable ?? false - } - - private var node: CatalogTreeNode? { - workflow.tree.node(withID: serverID) - } + private var node: CatalogTreeNode? { workflow.tree.node(withID: serverID) } var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - detailHeader - formContent + ScrollView { + if let facts = workflow.serverDetailPresentation(for: serverID) { + VStack(alignment: .leading, spacing: 24) { + header(facts) + if let node, node.isInvalid { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(node.invalidReasons.enumerated()), id: \.offset) { _, reason in + Label( + AppPresentation.message( + for: ActivationFailure.invalidLeaf(node: node.id, reason: reason)), + systemImage: "exclamationmark.triangle.fill" + ) + .font(.footnote) + .foregroundStyle(.orange) + } + } + } + VStack(alignment: .leading, spacing: 12) { + Text("连接信息").font(.headline) + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 12) { + informationRow("地址", value: facts.address) + informationRow("端口", value: String(facts.port)) + informationRow("加密方式", value: facts.encryptionMethod) + } + } + Divider() + pluginSection(facts.plugin) } - .padding(.leading, 28) - .padding(.trailing, 32) - .disabled(isSubmitting) - .padding(.top, 20) - .padding(.bottom, 24) - } - if isEditable { - detailFooter + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 28) + .padding(.vertical, 24) } } - .onAppear { fields.showServer(serverID, load: workflow.serverEditForm) } - .onChange(of: serverID) { _, _ in - fields.showServer(serverID, load: workflow.serverEditForm) - } - .onReceive(workflow.$tree) { _ in - fields.updatePresentation( - workflow.serverFormPresentation(for: serverID), load: workflow.serverEditForm) - } - .onReceive(workflow.subscriptionServerRefreshes) { affected in - fields.updatePresentation( - workflow.serverFormPresentation(for: serverID), load: workflow.serverEditForm) - fields.subscriptionDidRefresh(affectedServers: affected, load: workflow.serverEditForm) + .onAppear { parameters.showServer(serverID, load: workflow.serverDetailPluginOptions) } + .onReceive(workflow.serverDetailChanges) { affected in + parameters.didRefresh(affectedServers: affected, load: workflow.serverDetailPluginOptions) } } - // MARK: - 详情头与表单 + private func header(_ facts: ServerDetailPresentation) -> some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "server.rack") + .font(.title2) + .foregroundStyle(.tint) + .frame(width: 40, height: 40) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 4) { + Text(facts.name) + .font(.title2.weight(.semibold)) + .textSelection(.enabled) + HStack(spacing: 8) { + Text(node?.source == .subscription ? "订阅节点 · 远端管理" : "手动服务器") + if isActiveTarget { + Label("活动目标", systemImage: "bolt.fill") + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } - private var detailHeader: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.accentColor.opacity(0.12)) - Image(systemName: "server.rack") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(.tint) - } - .frame(width: 38, height: 38) - VStack(alignment: .leading, spacing: 2) { - Text(workflow.displayName(for: serverID)) - .font(.title2.weight(.semibold)) - .lineLimit(1) - Text(sourceDescription) - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer(minLength: 0) - if let node, node.isInvalid { - Image(systemName: "exclamationmark.triangle.fill") + private func informationRow(_ label: String, value: String) -> some View { + GridRow(alignment: .top) { + Text(label).foregroundStyle(.secondary) + Text(value).textSelection(.enabled) + } + } + + private func pluginSection(_ plugin: PluginSectionState) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("插件").font(.headline) + switch plugin.selection { + case .none: + Text("无").foregroundStyle(.secondary) + case .named(let program), .unknown(let program): + Text(program).textSelection(.enabled) + if let notice = plugin.availabilityWarning(for: program) { + Label(notice, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) .foregroundStyle(.orange) - .help("该服务器存在已知阻塞问题,激活会被点名拒绝") + } else if plugin.unresolvedProgram(for: plugin.selection) != nil { + Label("未找到插件“\(program)”。", systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.orange) + } + Text("插件参数") + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + if parameters.failure != nil { + VStack(alignment: .leading, spacing: 8) { + Label("无法读取插件参数", systemImage: "exclamationmark.triangle") + Button("重新加载") { parameters.reload(load: workflow.serverDetailPluginOptions) } + } + } else if plugin.optionsPresent { + PluginOptionsReadOnlyEditor(draft: parameters.options) + } else { + Text("无").foregroundStyle(.secondary) } } - .padding(.bottom, 16) - Divider() } } - private var sourceDescription: String { - guard let node else { return "" } - if node.source == .subscription { - return "订阅节点 · 远端管理" - } - return isActiveTarget ? "手动服务器 · 活动目标" : "手动服务器" - } - - @ViewBuilder - private var formContent: some View { - if let node, node.isInvalid { - invalidBanner(node) - } - if fields.hasLoadedServer { - if let failure = fields.loadFailure { - loadFailureNotice(failure, preservingDraft: true) - } - if !isEditable { - Label("订阅节点由远端管理:连接字段只读。", systemImage: "info.circle") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top, 16) - } - ServerFormFieldsGrid( - fields: fields, plugin: fields.presentation?.plugin, isEditable: isEditable, - fieldFocus: $fieldFocus - ) - .padding(.top, 20) - } else if let failure = fields.loadFailure { - loadFailureNotice(failure, preservingDraft: false) - } - } - - private func loadFailureNotice( - _ failure: ServerFormLoadError, preservingDraft: Bool - ) -> some View { - VStack(alignment: .leading, spacing: 8) { - Label("无法加载服务器资料", systemImage: "exclamationmark.triangle") - .font(.headline) - if preservingDraft { - Text("重新加载失败。当前草稿已保留,重新加载成功后才能保存。") - } else if failure == .notFound { - Text("未选择节点") - } else { - Text("无法读取服务器凭据。请重新加载后再试。") - } - Button("重新加载", action: loadForm) - .disabled(isSubmitting) - } - .font(.callout) - .padding(.top, 16) - } - - private func invalidBanner(_ node: CatalogTreeNode) -> some View { - VStack(alignment: .leading, spacing: 6) { - ForEach(Array(node.invalidReasons.enumerated()), id: \.offset) { _, reason in - Label( - AppPresentation.message( - for: ActivationFailure.invalidLeaf(node: node.id, reason: reason)), - systemImage: "exclamationmark.triangle.fill" - ) - .font(.footnote) - .foregroundStyle(.orange) - } - } - .padding(.top, 16) - } - - /// 底部操作区:重置恢复已保存值,保存提交草稿。 - private var detailFooter: some View { - VStack(spacing: 0) { - Divider() - HStack { - Spacer(minLength: 0) - Button("重置") { loadForm() } - .disabled(!fields.hasChanges || isSubmitting) - Button("保存") { save() } - .keyboardShortcut(.defaultAction) - .disabled(!fields.hasChanges || !fields.canSaveServer || isSubmitting) - } - .padding(.horizontal, 32) - .padding(.vertical, 12) - } - .background(.bar) - } - - // MARK: - 表单装载与提交 - - private func loadForm() { - fields.reloadServer(load: workflow.serverEditForm) - } - - private func save() { - guard !isSubmitting, fields.canSaveServer, fields.hasChanges else { return } - guard fields.validateForSubmit(), let draft = fields.draft else { - fieldFocus = fields.firstErrorField - return - } - let id = serverID - isSubmitting = true - Task { - defer { isSubmitting = false } - do { - try await workflow.updateServer(id, draft: draft) - fields.didSaveServer(id, load: workflow.serverEditForm) - } catch { - errors.present(error) - } - } - } } diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServerFormFields.swift b/ShadowsocksX-NG2/App/UI/Servers/ServerFormFields.swift index ebb23712..76a13a0a 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ServerFormFields.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ServerFormFields.swift @@ -30,7 +30,7 @@ enum ServerFormFieldError: Equatable { /// 服务器表单草稿(UI 持有):连接字段草稿状态与「字段 ↔ ServerEditForm/ /// ServerEditDraft 命令」的映射只在此写一次;新建与编辑两个表单面各自 /// `@StateObject` 持有一份,经 `load(from:)` 装载、`draft` 提交,第三个表单 -/// 面接入只需学这一对方法。栅格渲染见 `ServerFormFieldsGrid`;订阅只读禁用 +/// 面接入只需学这一对方法。栅格渲染见 `ServerFormFieldsGrid`;编辑资格 /// 与插件区 facts 由 caller 注入。 @MainActor final class ServerFormFields: ObservableObject { @@ -136,6 +136,7 @@ final class ServerFormFields: ObservableObject { let fields = ServerFormFields() fields.portText = "8388" fields.encryptionMethod = "aes-256-gcm" + fields.savedDraft = fields.draft return fields } @@ -192,8 +193,14 @@ final class ServerFormFields: ObservableObject { func updatePresentation( _ facts: ServerFormPresentation?, + preservingDraft: Bool = false, load: ((NodeID) throws -> ServerEditForm?)? = nil ) { + // Sheet 更新资格和可用性事实,保持打开时的草稿与基线。 + if preservingDraft { + if presentation != facts { presentation = facts } + return + } if let selection = facts?.plugin.selection, presentation?.plugin.selection != selection { diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServerFormSheet.swift b/ShadowsocksX-NG2/App/UI/Servers/ServerFormSheet.swift new file mode 100644 index 00000000..72e29bc5 --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Servers/ServerFormSheet.swift @@ -0,0 +1,156 @@ +import SwiftUI + +/// 新建与编辑共用表单生命周期;编辑身份与新建落点均在打开时固定。 +struct ServerFormSheet: View { + enum Operation: Identifiable { + case create(parent: NodeID?) + case edit(NodeID) + + var id: String { + switch self { + case .create: return "create" + case .edit(let id): return "edit-\(id.rawValue)" + } + } + } + + @ObservedObject var workflow: CatalogWorkflow + @StateObject private var errors = ErrorAlertPresenter() + let operation: Operation + let onCreated: (NodeID) -> Void + @Environment(\.dismiss) private var dismiss + @StateObject private var fields: ServerFormFields + @State private var isSubmitting = false + @State private var isConfirmingDiscard = false + @FocusState private var fieldFocus: ServerFormField? + + init( + workflow: CatalogWorkflow, operation: Operation, + onCreated: @escaping (NodeID) -> Void + ) { + self.workflow = workflow + self.operation = operation + self.onCreated = onCreated + // 独立草稿只在 sheet 打开时初始化,父视图更新不重建。 + switch operation { + case .create: _fields = StateObject(wrappedValue: ServerFormFields.newForm()) + case .edit: _fields = StateObject(wrappedValue: ServerFormFields()) + } + } + + private var editID: NodeID? { + if case .edit(let id) = operation { return id } + return nil + } + + private var canSubmit: Bool { + if editID != nil { return fields.hasChanges && fields.canSaveServer } + return true + } + + private var plugin: PluginSectionState? { + if editID != nil { return fields.presentation?.plugin } + return workflow.newFormPluginSection(selection: fields.pluginChoice) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(editID == nil ? "新建服务器" : "编辑服务器") + .font(.title2) + ScrollView { + VStack(alignment: .leading, spacing: 14) { + if editID != nil, fields.hasLoadedServer, fields.presentation == nil { + Label("服务器已不存在,无法保存。", systemImage: "exclamationmark.triangle") + } + if let failure = fields.loadFailure { + loadFailureNotice(failure) + } + if editID == nil || fields.hasLoadedServer { + ServerFormFieldsGrid( + fields: fields, plugin: plugin, isEditable: true, + fieldFocus: $fieldFocus) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + // 焦点框绘制在控件边界外,需在滚动区内部预留空间。 + .padding(8) + } + HStack { + Spacer() + Button("取消", role: .cancel, action: requestDismiss) + .keyboardShortcut(.cancelAction) + if editID != nil { + Button("重置") { fields.reloadServer(load: workflow.serverEditForm) } + .disabled(!fields.hasChanges || fields.presentation?.isEditable != true) + } + Button(editID == nil ? "创建" : "保存", action: submit) + .keyboardShortcut(.defaultAction) + .disabled(!canSubmit) + } + } + .disabled(isSubmitting) + .interactiveDismissDisabled(isSubmitting || fields.hasChanges) + .onExitCommand(perform: requestDismiss) + .alert("放弃更改?", isPresented: $isConfirmingDiscard) { + Button("继续编辑", role: .cancel) {} + Button("放弃更改", role: .destructive) { dismiss() } + } + .presentingErrors(errors) + .defaultFocus($fieldFocus, .name) + .padding(20) + .frame(minWidth: 600, idealWidth: 640, minHeight: 400, idealHeight: 620) + .onAppear { + if let editID { fields.showServer(editID, load: workflow.serverEditForm) } + } + .onReceive(workflow.$tree) { _ in + if let editID { + fields.updatePresentation( + workflow.serverFormPresentation(for: editID), preservingDraft: true) + } + } + } + + private func loadFailureNotice(_ failure: ServerFormLoadError) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label("无法加载服务器资料", systemImage: "exclamationmark.triangle") + .font(.headline) + if fields.hasLoadedServer { + Text("重新加载失败。当前草稿已保留,重新加载成功后才能保存。") + } else if failure == .notFound { + Text("服务器已不存在,无法保存。") + } else { + Text("无法读取服务器凭据。请重新加载后再试。") + } + Button("重新加载") { fields.reloadServer(load: workflow.serverEditForm) } + } + .font(.callout) + } + + private func requestDismiss() { + guard !isSubmitting else { return } + if fields.hasChanges { isConfirmingDiscard = true } else { dismiss() } + } + + private func submit() { + guard !isSubmitting, canSubmit else { return } + guard fields.validateForSubmit(), let draft = fields.draft else { + fieldFocus = fields.firstErrorField + return + } + isSubmitting = true + Task { + defer { isSubmitting = false } + do { + switch operation { + case .create(let parent): + onCreated(try await workflow.createServer(draft, into: parent)) + case .edit(let id): + try await workflow.updateServer(id, draft: draft) + } + dismiss() + } catch { + errors.present(error) + } + } + } +} diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServerSidebarRow.swift b/ShadowsocksX-NG2/App/UI/Servers/ServerSidebarRow.swift index dce1091b..fb191fb0 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ServerSidebarRow.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ServerSidebarRow.swift @@ -11,6 +11,7 @@ struct ServerSidebarRow: View { let activation: ActivationFeedbackState let activeTargetID: NodeID? let errors: ErrorAlertPresenter + let onEdit: (NodeID) -> Void let onRename: (NodeID) -> Void let onNewGroup: (NodeID?) -> Void let onMove: (NodeID) -> Void @@ -85,6 +86,8 @@ struct ServerSidebarRow: View { Divider() if node.isGroup { Button("重命名…") { onRename(node.id) } + } else { + Button("编辑…") { onEdit(node.id) } } Button("新建分组…") { onNewGroup(node.isGroup ? node.id : node.parentID) diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift b/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift index 72994500..99699eaa 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift @@ -1,10 +1,7 @@ import SwiftUI -/// 服务器 destination(spec #21 D11,issue #32/#34/#35/#41):承载服务器目录树、 -/// 详情编辑与分组命令。workspace destination 由外层主窗口壳持有(新壳在 -/// MainWindowView.swift,票 #53);全局导入表单由窗口壳呈现,本视图持有服务器 -/// 功能的 sheet、alert、确认弹窗与编辑草稿边界;selection 由外层绑定,以便订阅 -/// 删除仍能清除失效的服务器选择。 +/// 服务器目录、只读详情与分组命令;本视图持有新建/编辑 sheet 和确认弹窗。 +/// selection 由主窗口绑定,订阅删除可清除失效选择。 struct ServersView: View { @ObservedObject var workflow: CatalogWorkflow @@ -31,9 +28,8 @@ struct ServersView: View { @State private var renameTarget: NodeID? @State private var renameText = "" @State private var newGroupParent: NodeID? - @State private var newServerParent: NodeID? @State private var isPresentingNewGroup = false - @State private var isPresentingNewServer = false + @State private var serverFormOperation: ServerFormSheet.Operation? @State private var deleteTarget: NodeID? @State private var moveTarget: NodeID? @State private var rootDropHovering = false @@ -41,8 +37,6 @@ struct ServersView: View { @State var shareContext: ShareContext? var body: some View { - // 主窗口外壳(票 #53)已提供分栏框架;本分区用扁平双栏承载目录与详情 - // (票 #55 再按原型重排)。 HStack(alignment: .top, spacing: 0) { serverSidebar .frame(minWidth: 240, idealWidth: 280, maxWidth: 340) @@ -86,10 +80,10 @@ struct ServersView: View { ) { context in MoveNodeSheet(workflow: workflow, errors: errors, nodeID: context.nodeID) } - .sheet(isPresented: $isPresentingNewServer) { - NewServerSheet( - workflow: workflow, errors: errors, - parent: newServerParent, onCreated: selectCreatedNode) + .sheet(item: $serverFormOperation) { operation in + ServerFormSheet( + workflow: workflow, operation: operation, + onCreated: selectCreatedNode) } // 切换选中项时立即收起分享,避免显示旧服务器。 .onChange(of: selection) { @@ -151,6 +145,7 @@ struct ServersView: View { activation: activation, activeTargetID: activeTargetID, errors: errors, + onEdit: presentEditServer, onRename: { id in renameTarget = id renameText = workflow.displayName(for: id) @@ -238,13 +233,14 @@ extension ServersView { errors: errors) } else { ServerDetailView( - workflow: workflow, serverID: id, isActiveTarget: activeTargetID == id, - errors: errors) + workflow: workflow, serverID: id, isActiveTarget: activeTargetID == id + ) + .id(id) } } else { ContentUnavailableView( "未选择节点", systemImage: "sidebar.left", - description: Text("在左侧选择服务器或分组查看与编辑详情")) + description: Text("在左侧选择服务器或分组查看详情")) } } @@ -268,6 +264,16 @@ extension ServersView { } .help("新建分组") } + ToolbarItem(placement: .primaryAction) { + Button { + if let selection { presentEditServer(selection) } + } label: { + Label("编辑服务器", systemImage: "pencil") + } + .labelStyle(.iconOnly) + .help("编辑服务器") + .disabled(selection.flatMap { workflow.serverFormPresentation(for: $0) }?.isEditable != true) + } // 删除:作用于当前选中的服务器叶子或手动分组,确认弹窗与右键菜单共用 // deleteTarget 流(档位见 deleteFacts);无选中或选中项不可删(订阅节点 // 结构只读,GLOSSARY.md)即禁用,资格判定归政策 seam。 @@ -322,8 +328,12 @@ extension ServersView { } private func presentNewServer(in parent: NodeID?) { - newServerParent = parent - isPresentingNewServer = true + serverFormOperation = .create(parent: parent) + } + + private func presentEditServer(_ id: NodeID) { + guard workflow.serverFormPresentation(for: id)?.isEditable == true else { return } + serverFormOperation = .edit(id) } private func selectCreatedNode(_ id: NodeID) { diff --git a/ShadowsocksX-NG2/Tests/Servers/ServerFormLoadingTests.swift b/ShadowsocksX-NG2/Tests/Servers/ServerFormLoadingTests.swift index c3f91668..dfffee4e 100644 --- a/ShadowsocksX-NG2/Tests/Servers/ServerFormLoadingTests.swift +++ b/ShadowsocksX-NG2/Tests/Servers/ServerFormLoadingTests.swift @@ -214,6 +214,159 @@ struct ServerFormLoadingTests { } } +extension ServerFormLoadingTests { + @Test + func newFormStartsUnchangedAndReturningToDefaultsClearsChanges() { + let fields = ServerFormFields.newForm() + #expect(!fields.hasChanges) + fields.remark = "未保存" + #expect(fields.hasChanges) + fields.remark = "" + #expect(!fields.hasChanges) + fields.portText = "bad" + #expect(fields.hasChanges) + } + + @Test + func detailFactsAndOptionsRemainReadableWithoutPassword() async throws { + let credentials = FormCredentialStore() + try await Self.withWorkflow(credentials: credentials) { workflow in + let program = ManagedPluginCatalog.plugins[0].program + let id = try await workflow.createServer( + ServerEditDraft( + address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm", + password: "pw", remark: "服务器", plugin: .named(program: program), + pluginOptions: "tls;host=example.com;host=other.example;empty="), into: nil) + let passwordRef = try #require( + credentials.storage.storageSnapshot.first(where: { $0.value == "pw" })?.key) + var changedServers: [Set] = [] + let observation = workflow.serverDetailChanges.sink { changedServers.append($0) } + defer { observation.cancel() } + let saved = try #require(try workflow.serverEditForm(for: id)) + try await workflow.updateServer( + id, + draft: ServerEditDraft( + address: saved.address, port: saved.port, encryptionMethod: saved.encryptionMethod, + password: "pw", remark: saved.remark, plugin: saved.plugin.selection, + pluginOptions: "tls;host=example.com;host=other.example;empty=")) + #expect(changedServers == [[id]]) + try credentials.delete(CredentialReference(rawValue: passwordRef)) + credentials.reads = 0 + let facts = try #require(workflow.serverDetailPresentation(for: id)) + #expect(facts.name == "服务器") + #expect(facts.address == "203.0.113.1") + #expect(facts.plugin.options.isEmpty) + #expect(credentials.reads == 0) + #expect( + try workflow.serverDetailPluginOptions(for: id) + == "tls;host=example.com;host=other.example;empty=") + #expect(credentials.reads == 1) + #expect(throws: ServerFormLoadError.credentialsUnavailable) { + _ = try workflow.serverEditForm(for: id) + } + credentials.failReads = true + #expect(throws: ServerFormLoadError.credentialsUnavailable) { + _ = try workflow.serverDetailPluginOptions(for: id) + } + #expect(workflow.serverDetailPresentation(for: id)?.address == "203.0.113.1") + } + } + + @Test + func unknownPluginDetailReadsOptionsWithoutLoadingPassword() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = CatalogFileStore(fileURL: directory.appendingPathComponent("catalog.json")) + let credentials = FormCredentialStore() + let optionsRef = CredentialReference.fresh() + try credentials.save("opaque=one;opaque=two", for: optionsRef) + var catalog = ConfigurationCatalog() + let id = try catalog.addServer( + ServerFields( + address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm", + passwordRef: .fresh(), remark: "未知插件服务器", + pluginProgram: "unknown-test-plugin", pluginOptionsRef: optionsRef)) + try store.save(CatalogDocument(catalog: catalog)) + let workflow = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()), + credentials: credentials) + credentials.reads = 0 + #expect( + workflow.serverDetailPresentation(for: id)?.plugin.selection + == .unknown(program: "unknown-test-plugin")) + #expect(credentials.reads == 0) + #expect(try workflow.serverDetailPluginOptions(for: id) == "opaque=one;opaque=two") + #expect(credentials.reads == 1) + } + + @Test + func parameterSessionSwitchesIdentityAndOnlyReloadsAffectedRefreshes() { + let parameters = ServerDetailParameters() + let first = NodeID(rawValue: "first") + let second = NodeID(rawValue: "second") + var reads = 0 + parameters.showServer(first) { _ in + reads += 1 + return "tls;host=one;host=two;empty=" + } + #expect(parameters.options.rows.filter { !$0.isBlank }.count == 4) + parameters.showServer(first) { _ in + reads += 1 + return "tls;host=one;host=two;empty=" + } + parameters.didRefresh(affectedServers: [second]) { _ in + Issue.record("不相关刷新不应重载") + return "" + } + parameters.didRefresh(affectedServers: [first]) { _ in + reads += 1 + return "host=new" + } + #expect(reads == 3) + #expect(parameters.options.composedString == "host=new") + parameters.showServer(second) { _ in throw ServerFormLoadError.credentialsUnavailable } + #expect(parameters.options.composedString == "") + #expect(parameters.failure == .credentialsUnavailable) + parameters.reload { _ in "trailing\\" } + #expect(parameters.failure == nil) + #expect(parameters.options.mode == .rawText) + #expect(parameters.options.rawText == "trailing\\") + parameters.didRefresh(affectedServers: [second]) { _ in + throw ServerFormLoadError.credentialsUnavailable + } + #expect(parameters.options.rawText.isEmpty) + #expect(parameters.failure == .credentialsUnavailable) + } + + @Test + func editingPresentationChangesNeverReplaceDraftAndDeletionBlocksSaving() { + let fields = ServerFormFields() + let id = NodeID(rawValue: "manual") + let form = ServerEditForm( + address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm", + password: "pw", remark: "服务器", + plugin: PluginSectionState( + selection: .unknown(program: "plugin"), programs: [], mappingsUnreadable: false, + optionsPresent: true, options: ""), isEditable: true) + fields.showServer(id) { _ in form } + fields.remark = "未保存名称" + let updatedPlugin = PluginSectionState( + selection: .named(program: "plugin"), programs: [], mappingsUnreadable: false, + optionsPresent: true, options: "") + fields.updatePresentation( + ServerFormPresentation(isEditable: true, plugin: updatedPlugin), preservingDraft: true) + #expect(fields.pluginChoice == .unknown(program: "plugin")) + #expect(fields.remark == "未保存名称") + #expect(fields.canSaveServer) + fields.updatePresentation(nil, preservingDraft: true) + #expect(!fields.canSaveServer) + #expect(fields.hasLoadedServer) + #expect(fields.password == "pw") + #expect(fields.remark == "未保存名称") + } + +} + private final class FormCredentialStore: CredentialStoring { let storage = InMemoryCredentialStore() var reads = 0 diff --git a/docs/adr/0030-server-detail-reads-plugin-options-separately.md b/docs/adr/0030-server-detail-reads-plugin-options-separately.md new file mode 100644 index 00000000..f66af3d0 --- /dev/null +++ b/docs/adr/0030-server-detail-reads-plugin-options-separately.md @@ -0,0 +1,9 @@ +# 服务器详情与编辑凭据装载分离 + +Status: Accepted (2026-10-06). + +服务器详情展示已保存连接资料和插件参数,不展示或读取密码;完整编辑草稿只在编辑 sheet 中装载。详情通过独立的参数读取 interface 获取插件参数,读取失败仅影响参数区。这使密码不可读时普通资料仍可浏览,也避免详情为展示参数而依赖完整编辑草稿。 + +插件参数仍属于敏感信息,但用户明确选择在只读详情中直接查看,以支持订阅服务器及未知插件的参数核对。参数明文仅在详情会话中持有,不加入非秘密展示投影或持久快照。相较于只显示“已配置”,这里接受主动浏览详情时读取参数的取舍;相较于复用编辑装载,这里保留密码与参数各自独立的读取范围和失败处理。 + +ADR-0028 的密码自动填充关闭策略继续适用于新建与编辑 sheet 中共用的密码输入字段;原有详情页表单被只读呈现替代。 diff --git a/docs/design/server-read-only-detail-and-edit-sheet.md b/docs/design/server-read-only-detail-and-edit-sheet.md new file mode 100644 index 00000000..cfa19ccd --- /dev/null +++ b/docs/design/server-read-only-detail-and-edit-sheet.md @@ -0,0 +1,33 @@ +# 服务器只读详情与编辑 sheet + +日期:2026-10-06。交互决策与实现范围已确认。本文件记录本次任务规格,不代表当前代码现状。 + +## 已确认交互 + +- 详情显示已保存资料,采用分区标题与标签/值排版,不使用禁用表单控件。顶部显示名称、来源与活动目标标记;连接信息显示地址、端口和加密方式,文本可选择复制;插件区显示插件名称、可用性及参数;已知阻塞问题独立呈现。详情移除密码、保存与重置操作。 +- 插件参数沿用只读 table 的“参数名/值/类型”列,保留顺序、重复参数、开关与空值的区别。保留“参数列表/原始文本”切换,长值可从原始文本完整查看;不可解析时显示原始文本。未知或未安装插件仍显示已保存参数。 +- 详情不读取密码。参数在进入详情及当前服务器保存或对应订阅刷新成功后加载;读取失败仅影响参数区,提供“重新加载”,其他信息继续展示。切换服务器时不得显示上一服务器的参数。 +- 工具栏增加 `pencil` 图标编辑按钮,仅选中手动服务器时可用;手动服务器右键菜单增加“编辑…”,作用于被点击的服务器。订阅服务器工具栏编辑禁用,菜单省略编辑项。已知有阻塞问题的手动服务器仍可编辑。 +- 编辑 sheet 共用新建表单的字段布局、草稿映射与校验。底部为“取消/重置/保存”;有修改且成功装载可编辑服务器时才允许保存;重置恢复已保存值。保留已有凭据加载失败提示、重载和保存门禁。 +- 新建与编辑共用的表单滚动内容留出 8pt padding,避免焦点框被裁切;sheet 最小宽度为 580pt、建议宽度为 640pt,为参数列表留出更多空间。插件可用性分支与警告文案共用一份映射,未知引用的操作说明由各视图保留。 +- 保存成功关闭 sheet 并更新详情;失败保留草稿。提交期间禁止重复提交、重置和关闭。 +- 新建与编辑统一处理未保存修改:取消或 Escape 在有修改时显示“放弃更改?”,按钮为“继续编辑/放弃更改”;无修改直接关闭。其他可关闭 sheet 的路径采用相同放弃规则。 +- 编辑目标在打开时固定。每次重新打开读取最新保存值,关闭后不保留草稿;目录更新不覆盖当前编辑内容。目标被删除时禁止保存并提示。 + +## Module 与 seam + +只读详情与编辑表单具有独立生命周期。详情从已提交配置获取展示事实,通过单独的参数读取 interface 获取已保存插件参数;参数读取失败不阻断普通事实展示。不得用读取完整 `ServerEditForm` 的方式满足详情需求。 + +新建与编辑共用 `ServerFormFields` 和字段栅格;共用 sheet 的布局、提交门禁与放弃修改行为,创建/更新命令保持各自意图。sheet 在 `ServersView` 中持有固定的编辑身份,工具栏和右键菜单调用同一个编辑入口。避免为两种操作各复制一套校验和关闭规则,也避免建立仅转发既有命令的 adapter。 + +只读参数视图复用解析与呈现能力,不复用编辑动作;保留旧数据的无损表示。参数明文仅在当前详情会话中持有,不加入非秘密投影或持久快照。 + +## 验证范围 + +验证详情不读取密码、参数独立加载及失败隔离、未知插件参数可见、切换服务器与订阅刷新行为。验证编辑目标固定、手动/订阅资格、草稿保留与重载、创建/保存成功关闭、失败保留以及放弃修改行为。按仓库要求执行格式、lint、相关 hostless 测试和构建;原生布局、table、sheet 与 Escape 交互需单独验证,不能以 hostless 测试代替。 + +## 本次验证记录 + +2026-10-06:格式化、lint 与 diff 检查完成;lint 有长度和复杂度等非阻断警告。全量 arm64 hostless 测试通过 958 个测试、1,038 次执行,零失败、零跳过;两组离线规则转换检查共 21 个测试通过。ad-hoc 应用构建通过。 + +原生详情布局、table、sheet、Escape 与其他关闭路径尚未人工验证;上述测试与构建结果不等于原生 UI 或真实系统代理行为验证。本次不涉及 sslocal 运行契约调整,未运行 smoke。