feat(servers): separate details from edit forms

This commit is contained in:
Qiu Yuzhou
2026-10-06 12:25:13 +08:00
parent e44a8345f4
commit bdcbc6cd60
14 changed files with 570 additions and 282 deletions
@@ -69,6 +69,13 @@ final class CatalogWorkflow: ObservableObject {
subscriptionServerRefreshSubject.eraseToAnyPublisher()
}
private let serverUpdateSubject = PassthroughSubject<Set<NodeID>, Never>()
/// 成功保存或订阅刷新后装载详情参数,包括只改变凭据而树值不变的提交。
var serverDetailChanges: AnyPublisher<Set<NodeID>, 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())
}
@@ -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
}
@@ -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 "无法读取插件文件信息,请检查路径和访问权限。"
}
}
}
@@ -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)
}
}
}
}
@@ -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] {
@@ -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<NodeID>, load: (NodeID) throws -> String) {
guard let serverID, affectedServers.contains(serverID) else { return }
reload(load: load)
}
}
@@ -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)
}
}
}
}
@@ -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
{
@@ -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)
}
}
}
}
@@ -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)
@@ -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) {
@@ -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<NodeID>] = []
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
@@ -0,0 +1,9 @@
# 服务器详情与编辑凭据装载分离
Status: Accepted (2026-10-06).
服务器详情展示已保存连接资料和插件参数,不展示或读取密码;完整编辑草稿只在编辑 sheet 中装载。详情通过独立的参数读取 interface 获取插件参数,读取失败仅影响参数区。这使密码不可读时普通资料仍可浏览,也避免详情为展示参数而依赖完整编辑草稿。
插件参数仍属于敏感信息,但用户明确选择在只读详情中直接查看,以支持订阅服务器及未知插件的参数核对。参数明文仅在详情会话中持有,不加入非秘密展示投影或持久快照。相较于只显示“已配置”,这里接受主动浏览详情时读取参数的取舍;相较于复用编辑装载,这里保留密码与参数各自独立的读取范围和失败处理。
ADR-0028 的密码自动填充关闭策略继续适用于新建与编辑 sheet 中共用的密码输入字段;原有详情页表单被只读呈现替代。
@@ -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。