mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-28 21:23:44 +00:00
feat(servers): manage subscription root links
This commit is contained in:
@@ -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? {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NodeID>) -> 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<NodeID>) {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user