From 9bccdf460afb8716e5ba7a7d5c0eaea7a4eae697 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Wed, 14 Oct 2026 23:04:25 +0800 Subject: [PATCH] feat(servers): manage subscription root links --- .../Presentation/NodeDetailPresentation.swift | 12 +- .../UI/Servers/CatalogNodeDetailView.swift | 2 +- .../App/UI/Servers/ServerTableNameCell.swift | 7 + .../App/UI/Servers/ServersView.swift | 161 ++++++++++++++---- .../UI/Subscriptions/SubscriptionsView.swift | 20 ++- .../Catalog/CatalogTreeProjectionTests.swift | 25 +++ 6 files changed, 180 insertions(+), 47 deletions(-) diff --git a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift index 5fdfb787..e86f589f 100644 --- a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift +++ b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift @@ -1,5 +1,11 @@ import Foundation +extension CatalogTreeNode { + var isRootSubscriptionGroup: Bool { + isGroup && source == .subscription && parentID == nil + } +} + /// 详情会话的展示值;包含参数明文,不进入目录工作流的非秘密投影或持久快照。 @MainActor struct NodeDetailPresentation { @@ -26,7 +32,7 @@ struct NodeDetailPresentation { locale: Locale = .current, timeZone: TimeZone = .current ) { title = node.name - let isRootSubscriptionGroup = Self.isRootSubscriptionGroup(node) + let isRootSubscriptionGroup = node.isRootSubscriptionGroup secondarySubtitle = isRootSubscriptionGroup ? String(localized: "配置由远端管理,本地不能修改。", table: "ServerDetails") @@ -79,10 +85,6 @@ struct NodeDetailPresentation { if let server { properties.append(Self.connection(for: server)) } } - private static func isRootSubscriptionGroup(_ node: CatalogTreeNode) -> Bool { - node.isGroup && node.source == .subscription && node.parentID == nil - } - private static func subscriptionInformation( summary: SubscriptionSummary?, locale: Locale, timeZone: TimeZone ) -> Properties? { diff --git a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift index 374dd29a..65843701 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift @@ -107,7 +107,7 @@ struct CatalogNodeDetailView: View { } private func subscriptionSummary(for node: CatalogTreeNode) -> SubscriptionSummary? { - guard node.isGroup, node.source == .subscription, node.parentID == nil else { return nil } + guard node.isRootSubscriptionGroup else { return nil } return workflow.subscriptions.first { $0.groupID == node.id } } } diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServerTableNameCell.swift b/ShadowsocksX-NG2/App/UI/Servers/ServerTableNameCell.swift index 3ef13d57..10858670 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ServerTableNameCell.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ServerTableNameCell.swift @@ -39,6 +39,7 @@ struct ServerTableNameCell: View { struct ServerNodeContextMenu: View { let node: CatalogTreeNode let workflow: CatalogWorkflow + let allowsSubscriptionManagement: Bool /// 激活反馈共享状态:命令经它发出(单飞互斥、结果记录在会话反馈里)。 let activation: ActivationFeedbackState let errors: ErrorAlertPresenter @@ -96,6 +97,12 @@ struct ServerNodeContextMenu: View { Button("删除…", role: .destructive) { onDelete(node.id) } + } else if allowsSubscriptionManagement, node.isRootSubscriptionGroup { + Divider() + Button("编辑 URL…") { onEdit(node.id) } + Button("删除…", role: .destructive) { + onDelete(node.id) + } } } } diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift b/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift index fed1ffd9..b2fce522 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift @@ -30,7 +30,8 @@ struct ServersView: View { @State private var newGroupParent: NodeID? @State private var isPresentingNewGroup = false @State private var serverFormOperation: ServerFormSheet.Operation? - @State private var deleteTarget: NodeID? + @State private var subscriptionEditTarget: SubscriptionSummary? + @State private var deleteTarget: DeleteTarget? @State private var dropState = ServerTableDropState() @FocusState private var isServerTableFocused: Bool // 分享 popover 打开时冻结载荷、顶部资料与建议文件名。 @@ -71,6 +72,9 @@ struct ServersView: View { workflow: workflow, errors: errors, parent: newGroupParent, onCreated: selectCreatedNode) } + .sheet(item: $subscriptionEditTarget) { summary in + EditSubscriptionURLSheet(workflow: workflow, errors: errors, summary: summary) + } .presentingErrors(errors) .confirmationDialog( deleteTitle, @@ -79,7 +83,7 @@ struct ServersView: View { set: { if !$0 { deleteTarget = nil } }), titleVisibility: .visible ) { - Button("删除", role: .destructive) { commitDelete() } + Button(deleteConfirmationActionTitle, role: .destructive) { commitDelete() } Button("取消", role: .cancel) { deleteTarget = nil } } message: { Text(deleteMessage) @@ -169,6 +173,9 @@ struct ServersView: View { } rows: { ForEach(rows) { row in TableRow(row.node) + .contextMenu { + nodeContextMenu(for: row.node, allowsSubscriptionManagement: section == .subscription) + } .itemProvider { guard let payload = workflow.dragPayload(for: row.id) else { return nil } return dropState.provider(for: row.id, payload: payload) @@ -189,7 +196,6 @@ struct ServersView: View { if !isServerTableFocused { isServerTableFocused = true } } ) - .contextMenu(forSelectionType: NodeID.self, menu: nodeContextMenu) .onKeyPress(.escape) { guard selection != nil else { return .ignored } selection = nil @@ -209,14 +215,24 @@ struct ServersView: View { @ViewBuilder private func nodeContextMenu(_ ids: Set) -> some View { if let id = ids.first, let node = workflow.tree.node(withID: id) { - ServerNodeContextMenu( - node: node, workflow: workflow, activation: activation, errors: errors, - onEdit: presentEditServer, onRename: { renameTarget = $0 }, - onNewGroup: presentNewGroup, onDuplicate: duplicateNode, - onDelete: { deleteTarget = $0 }, onExport: exportConfigurationGroup) + nodeContextMenu(for: node, allowsSubscriptionManagement: false) } } + @ViewBuilder + private func nodeContextMenu( + for node: CatalogTreeNode, allowsSubscriptionManagement: Bool + ) -> some View { + ServerNodeContextMenu( + node: node, workflow: workflow, allowsSubscriptionManagement: allowsSubscriptionManagement, + activation: activation, errors: errors, + onEdit: presentEditNode, onRename: { renameTarget = $0 }, + onNewGroup: presentNewGroup, onDuplicate: duplicateNode, + onDelete: { + requestDelete($0, allowsSubscriptionManagement: allowsSubscriptionManagement) + }, onExport: exportConfigurationGroup) + } + /// 删除结果 → selection invalidation:仅清除失效选择,不自动 /// 改选相邻节点。 private func clearSelectionIfInvalidated(_ removed: Set) { @@ -327,13 +343,13 @@ extension ServersView { } ToolbarItem(placement: .primaryAction) { Button { - if let selection { presentEditServer(selection) } + if let selection { presentEditNode(selection) } } label: { - Label("编辑服务器", systemImage: "pencil") + Label(toolbarEditTitle, systemImage: "pencil") } .labelStyle(.iconOnly) - .help("编辑服务器") - .disabled(selection.flatMap { workflow.serverFormPresentation(for: $0) }?.isEditable != true) + .help(toolbarEditTitle) + .disabled(!canEditSelection) } ToolbarItem(placement: .primaryAction) { Button { @@ -344,12 +360,12 @@ extension ServersView { .help("复制") .disabled(selection.map(workflow.canDuplicate) != true) } - // 删除:作用于当前选中的服务器叶子或手动分组,确认弹窗与右键菜单共用 - // deleteTarget 流(档位见 deleteFacts);无选中或选中项不可删(订阅节点 - // 结构只读,GLOSSARY.md)即禁用,资格判定归政策 seam。 + // 删除:手动节点走目录政策;订阅根组走订阅管理。 ToolbarItem(placement: .primaryAction) { Button { - deleteTarget = selection + if let selection { + requestDelete(selection, allowsSubscriptionManagement: section == .subscription) + } } label: { Label("删除", systemImage: "trash") } @@ -387,9 +403,43 @@ extension ServersView { // MARK: - 告警动作 - /// 工具栏删除钮可用性:无选中或选中项不可删(订阅节点、失效选择)即禁用。 + /// 工具栏删除钮仅对手动节点和订阅根组启用。 private var canDeleteSelection: Bool { - selection.map(workflow.canDelete) ?? false + guard let selection, let node = workflow.tree.node(withID: selection) else { return false } + if section == .subscription, node.isRootSubscriptionGroup { + return subscriptionSummary(forRootGroup: selection) != nil + } + return workflow.canDelete(selection) + } + + private var canEditSelection: Bool { + guard let selection, let node = workflow.tree.node(withID: selection) else { return false } + if section == .subscription, node.isRootSubscriptionGroup { + return subscriptionSummary(forRootGroup: selection) != nil + } + return workflow.serverFormPresentation(for: selection)?.isEditable == true + } + + private var toolbarEditTitle: String { + guard section == .subscription, let selection, + workflow.tree.node(withID: selection)?.isRootSubscriptionGroup == true + else { return "编辑服务器" } + return "编辑 URL…" + } + + private func subscriptionSummary(forRootGroup id: NodeID) -> SubscriptionSummary? { + workflow.subscriptions.first { $0.groupID == id } + } + + private func requestDelete(_ id: NodeID, allowsSubscriptionManagement: Bool) { + guard let node = workflow.tree.node(withID: id) else { return } + if allowsSubscriptionManagement, node.isRootSubscriptionGroup, + let summary = subscriptionSummary(forRootGroup: id) + { + deleteTarget = .subscription(summary) + } else if workflow.canDelete(id) { + deleteTarget = .node(id) + } } private func presentNewGroup(in parent: NodeID?) { @@ -406,6 +456,17 @@ extension ServersView { serverFormOperation = .edit(id) } + private func presentEditNode(_ id: NodeID) { + guard let node = workflow.tree.node(withID: id) else { return } + if node.isRootSubscriptionGroup, + let summary = subscriptionSummary(forRootGroup: id) + { + subscriptionEditTarget = summary + } else { + presentEditServer(id) + } + } + private func selectCreatedNode(_ id: NodeID) { section = .manual expansion.reveal(id, in: workflow.tree) @@ -425,35 +486,60 @@ extension ServersView { /// 删除确认文案:档位与规模事实来自 seam;句子由 UI 拼(Q3-A)。 private var deleteTitle: String { - guard let id = deleteTarget else { return "" } - let name = workflow.displayName(for: id) - switch workflow.deleteFacts(for: id) { - case .subtree: - return "删除分组「\(name)」及其整棵子树?" - case .leaf, .emptyGroup, nil: - return "删除「\(name)」?" + switch deleteTarget { + case .some(.subscription(let summary)): + return SubscriptionDeletionConfirmation.title(for: summary.name) + case .some(.node(let id)): + let name = workflow.displayName(for: id) + switch workflow.deleteFacts(for: id) { + case .subtree: + return "删除分组「\(name)」及其整棵子树?" + case .leaf, .emptyGroup, nil: + return "删除「\(name)」?" + } + case .none: + return "" } } private var deleteMessage: String { - guard let id = deleteTarget else { return "" } - switch workflow.deleteFacts(for: id) { - case .subtree(let count, let includesCredentials): - if includesCredentials { - return "将递归删除 \(count) 个节点(含其中的服务器与凭据),此操作不可撤销。" + switch deleteTarget { + case .some(.subscription): + return SubscriptionDeletionConfirmation.message + case .some(.node(let id)): + switch workflow.deleteFacts(for: id) { + case .subtree(let count, let includesCredentials): + if includesCredentials { + return "将递归删除 \(count) 个节点(含其中的服务器与凭据),此操作不可撤销。" + } + return "将递归删除 \(count) 个节点,此操作不可撤销。" + case .leaf, .emptyGroup, nil: + return "此操作不可撤销。" } - return "将递归删除 \(count) 个节点,此操作不可撤销。" - case .leaf, .emptyGroup, nil: - return "此操作不可撤销。" + case .none: + return "" } } + private var deleteConfirmationActionTitle: String { + if case .some(.subscription) = deleteTarget { + return SubscriptionDeletionConfirmation.actionTitle + } + return "删除" + } + private func commitDelete() { - guard let id = deleteTarget else { return } + guard let target = deleteTarget else { return } deleteTarget = nil Task { do { - let outcome = try await workflow.remove(id) + let outcome: RemovalOutcome + switch target { + case .node(let id): + outcome = try await workflow.remove(id) + case .subscription(let summary): + outcome = try await workflow.removeSubscription(summary.id) + } clearSelectionIfInvalidated(outcome.removedNodeIDs) } catch { errors.present(error) @@ -467,3 +553,8 @@ private struct NodeContext: Identifiable { let nodeID: NodeID var id: NodeID { nodeID } } + +private enum DeleteTarget { + case node(NodeID) + case subscription(SubscriptionSummary) +} diff --git a/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionsView.swift b/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionsView.swift index a112e225..57b28637 100644 --- a/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionsView.swift +++ b/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionsView.swift @@ -1,5 +1,15 @@ import SwiftUI +enum SubscriptionDeletionConfirmation { + static func title(for name: String) -> String { + "删除订阅「\(name)」及其整棵子树?" + } + + static let actionTitle = "删除订阅" + static let message = + "将移除订阅源、固定分组与全部远端成员;若代理正走此订阅,代理会停止。此操作不可撤销。" +} + /// 订阅分区:每个订阅以一张卡片呈现 /// (图标 + 名称 + 「HTTPS 订阅地址已隐藏」+ 状态徽标 + 更新/重试 + 「…」菜单 + /// 三列元数据 + 脱敏说明行),失败卡警告色描边。「添加订阅」与「更新全部」 @@ -53,18 +63,16 @@ struct SubscriptionsView: View { AddSubscriptionSheet(workflow: workflow, errors: errors) } .confirmationDialog( - "删除订阅「\(deleteTarget?.name ?? "")」及其整棵子树?", + SubscriptionDeletionConfirmation.title(for: deleteTarget?.name ?? ""), isPresented: Binding( get: { deleteTarget != nil }, set: { if !$0 { deleteTarget = nil } }), titleVisibility: .visible ) { - Button("删除订阅", role: .destructive) { commitDelete() } + Button(SubscriptionDeletionConfirmation.actionTitle, role: .destructive) { commitDelete() } Button("取消", role: .cancel) { deleteTarget = nil } } message: { - Text( - "将移除订阅源、固定分组与全部远端成员;若代理正走此订阅,代理会停止。此操作不可撤销。" - ) + Text(SubscriptionDeletionConfirmation.message) } .presentingErrors(errors) .toolbar { @@ -386,7 +394,7 @@ struct AddSubscriptionSheet: View { /// 「编辑订阅 URL」表单(保留订阅与固定分组身份);保存后立即刷新。 /// URL 明文经显式命令读取。 -private struct EditSubscriptionURLSheet: View { +struct EditSubscriptionURLSheet: View { let workflow: CatalogWorkflow let errors: ErrorAlertPresenter let summary: SubscriptionSummary diff --git a/ShadowsocksX-NG2/Tests/Catalog/CatalogTreeProjectionTests.swift b/ShadowsocksX-NG2/Tests/Catalog/CatalogTreeProjectionTests.swift index 92c97faf..b0ba365a 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/CatalogTreeProjectionTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/CatalogTreeProjectionTests.swift @@ -89,6 +89,31 @@ struct CatalogTreeTimestampProjectionTests { } } +struct SubscriptionRootGroupClassificationTests { + private func node( + _ id: String, source: NodeSource, parentID: NodeID?, isGroup: Bool = true + ) -> CatalogTreeNode { + CatalogTreeNode( + id: NodeID(rawValue: id), name: id, isGroup: isGroup, source: source, parentID: parentID, + createdAt: nil, updatedAt: nil, invalidReasons: [], childCount: 0, + subtreeNodeCount: 0, invalidDescendantCount: 0, children: isGroup ? [] : nil) + } + + @Test + func onlyRootSubscriptionGroupsQualify() { + let rootID = NodeID(rawValue: "subscription-root") + let root = node("subscription-root", source: .subscription, parentID: nil) + let nested = node("nested-subscription-group", source: .subscription, parentID: rootID) + let manual = node("manual-group", source: .manual, parentID: nil) + let server = node("subscription-server", source: .subscription, parentID: nil, isGroup: false) + + #expect(root.isRootSubscriptionGroup) + #expect(!nested.isRootSubscriptionGroup) + #expect(!manual.isRootSubscriptionGroup) + #expect(!server.isRootSubscriptionGroup) + } +} + struct ServerTableOrderingTests { @Test func hierarchySortsEachSiblingSetAndCollapseHidesOnlyThatSubtree() throws {