feat(ui): 服务器工具栏新增删除按钮

- 工具栏次序:新建服务器、新建分组、删除、分享;删除钮作用于当前
  选中节点,确认弹窗与右键菜单共用 deleteTarget 流(档位见
  deleteFacts)。
- 无选中、选择失效或选中项不可删除时禁用;资格判定新增政策缝
  CatalogWorkflow.canDelete(节点存在且 manual),订阅节点结构只读
  不经此口径,订阅删除仍留在订阅分区。
- CONTEXT.md Vocabulary 增补 Deletable node 术语;新增 canDelete
  接口测试。
This commit is contained in:
Qiu Yuzhou
2026-10-04 14:56:48 +08:00
parent fab2004aa5
commit edaa492257
4 changed files with 43 additions and 0 deletions
+1
View File
@@ -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.
@@ -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? {
+18
View File
@@ -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 = ""
@@ -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 {