From edaa492257a15feb79150b53db4e381f18ab8a22 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sun, 4 Oct 2026 14:56:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=A0=8F=E6=96=B0=E5=A2=9E=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工具栏次序:新建服务器、新建分组、删除、分享;删除钮作用于当前 选中节点,确认弹窗与右键菜单共用 deleteTarget 流(档位见 deleteFacts)。 - 无选中、选择失效或选中项不可删除时禁用;资格判定新增政策缝 CatalogWorkflow.canDelete(节点存在且 manual),订阅节点结构只读 不经此口径,订阅删除仍留在订阅分区。 - CONTEXT.md Vocabulary 增补 Deletable node 术语;新增 canDelete 接口测试。 --- CONTEXT.md | 1 + .../CatalogWorkflow+Policy.swift | 8 ++++++++ ShadowsocksX-NG2/App/ServersView.swift | 18 ++++++++++++++++++ .../Tests/CatalogWorkflowPolicyTests.swift | 16 ++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index d0a9a90a..32fbee9f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,6 +12,7 @@ - **Manual group**: A configuration group owned by the user. It may contain only manually owned nodes; deleting it recursively deletes its descendants. Deletion is always user-confirmed: one confirmation for an empty manual group, and a second confirmation naming the recursive subtree scale when it is non-empty. - **Subscription**: A remote configuration source with a persistent opaque UUID and a fixed subscription-group identity. Its URL is a mutable endpoint/credential reference; editing the URL keeps the source identity, while deleting and creating a subscription establishes a new source identity. - **Subscription group**: The fixed, source-owned configuration subtree provided by one subscription. Users cannot move or structurally edit its nodes. A subscription server's user-visible name is one remote-derived value from `remarks`, falling back to its address and port when absent, empty, or whitespace-only; each refresh derives that name from the latest remote record. It has no local alias or note override. +- **Deletable node**: A manually owned node — a server configuration or a manual group — that the user can delete from the catalog. Subscription groups and their remote members are structurally read-only and are not deletable nodes; removing a subscription is subscription management, distinct from node deletion. _Avoid_: removable node. - **Active target**: The persisted server configuration or configuration group selected for proxying. When a group is active, the group UUID remains the target even when `sslocal` chooses an individual descendant server. - **Activation candidate**: A server configuration with no known local blocking problem for proxy configuration generation. This status does not guarantee remote reachability or correctness of opaque plugin options. - **Managed plugin**: A SIP003 plugin executable that the product packages, hash-verifies, re-signs, and distributes only inside the app bundle. The managed set changes only with app releases; users cannot supply their own plugin executables. diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift index f3eca381..5066dcfa 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift @@ -6,6 +6,14 @@ import Foundation extension CatalogWorkflow { // MARK: - 删除确认事实 + /// 删除资格(命令入口的统一 disable 判据,如分区工具栏删除钮):节点存在 + /// 且来源为 manual。订阅节点结构只读(CONTEXT.md),其移除属订阅分区流程, + /// 不经此口径。 + func canDelete(_ id: NodeID) -> Bool { + guard let node = tree.node(withID: id) else { return false } + return node.isManual + } + /// 删除确认档位(CONTEXT.md:空手动组/服务器单次;非空手动组二次并点名规模)。 /// 节点不存在时为 `nil`。 func deleteFacts(for id: NodeID) -> DeleteConfirmKind? { diff --git a/ShadowsocksX-NG2/App/ServersView.swift b/ShadowsocksX-NG2/App/ServersView.swift index 54e76556..b6003ec3 100644 --- a/ShadowsocksX-NG2/App/ServersView.swift +++ b/ShadowsocksX-NG2/App/ServersView.swift @@ -265,6 +265,19 @@ extension ServersView { .labelStyle(.iconOnly) .help("新建分组") } + // 删除:作用于当前选中的服务器叶子或手动分组,确认弹窗与右键菜单共用 + // deleteTarget 流(档位见 deleteFacts);无选中或选中项不可删(订阅节点 + // 结构只读,CONTEXT.md)即禁用,资格判定归政策 seam。 + ToolbarItem(placement: .primaryAction) { + Button { + deleteTarget = selection + } label: { + Label("删除", systemImage: "trash") + } + .labelStyle(.iconOnly) + .help("删除") + .disabled(!canDeleteSelection) + } // 分享(分享/导出术语见 CONTEXT.md):作用于选中的服务器叶子,弹窗内 // 提供二维码与复制 ss:// 链接两条通路;无有效载荷即不可用。 ToolbarItem(placement: .primaryAction) { @@ -288,6 +301,11 @@ extension ServersView { // MARK: - 告警动作 + /// 工具栏删除钮可用性:无选中或选中项不可删(订阅节点、失效选择)即禁用。 + private var canDeleteSelection: Bool { + selection.map(workflow.canDelete) ?? false + } + private func presentNewGroup(in parent: NodeID?) { newGroupParent = parent newGroupName = "" diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift index 6b3680bc..6618d113 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift @@ -79,6 +79,22 @@ final class CatalogWorkflowPolicyTests: XCTestCase { .subtree(count: 3, includesCredentials: true)) } + // MARK: - 删除资格 + + func testCanDeleteManualLeafAndGroupButNotSubscriptionOrMissing() async throws { + let serverID = try await importServer() + let groupID = try await workflow.createGroup(named: "组", into: nil) + XCTAssertTrue(workflow.canDelete(serverID), "手动叶子可删") + XCTAssertTrue(workflow.canDelete(groupID), "手动分组可删(空组同理)") + + let fixture = try CatalogFixtures.makeSubscriptionFixture() + try CatalogFileStore(fileURL: fileURL).save(CatalogDocument(catalog: fixture.catalog)) + workflow = makeWorkflow() + XCTAssertFalse(workflow.canDelete(fixture.serverIDs[0]), "订阅成员结构只读") + XCTAssertFalse(workflow.canDelete(fixture.groupID), "订阅分组结构只读") + XCTAssertFalse(workflow.canDelete(NodeID(rawValue: "ghost")), "节点不存在") + } + // MARK: - 移动目的地 func testMoveDestinationsAreRootAndManualGroupsExcludingOwnSubtree() async throws {