Files
Qiu Yuzhou ba01e305c5 feat(servers): mark groups containing the active target with a hollow bolt
Ancestor groups of the active target now show a hollow bolt beside the
existing filled bolt on the target itself, in the servers tree, the
favorites table and the home target browser; the node detail subtitle
shows "含活动目标". The derivation lives in
CatalogTreeSnapshot.ancestors(of:) so every surface shares one rule
while activeTargetID stays a runtime fact outside the catalog
projection. Groups activated directly keep the filled bolt and leave
their member rows unmarked.
2026-10-20 01:03:54 +08:00

114 lines
3.7 KiB
Swift

import SwiftUI
/// Table 名称列:名称占剩余宽度,活动目标在列尾。实心闪电标记活动目标
/// 自身,空心闪电标记子树含活动目标的分组(严格祖先)。
struct ServerTableNameCell: View {
let node: CatalogTreeNode
let activeTargetID: NodeID?
let containsActiveTarget: Bool
var body: some View {
HStack(spacing: 6) {
Image(systemName: node.isGroup ? "folder" : "server.rack")
Text(node.name)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(node.isInvalid ? .secondary : .primary)
if node.isInvalid {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.help(
node.invalidReasons.first.map {
AppPresentation.message(
for: ActivationFailure.invalidLeaf(node: node.id, reason: $0))
} ?? String(localized: "服务器存在已知阻塞问题"))
} else if node.invalidDescendantCount > 0 {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange)
.help("包含 \(node.invalidDescendantCount) 个存在已知阻塞问题的服务器")
}
if activeTargetID == node.id {
Image(systemName: "bolt.fill")
.foregroundStyle(.orange)
.help("活动目标")
} else if containsActiveTarget {
Image(systemName: "bolt")
.foregroundStyle(.orange)
.help("组内含活动目标")
}
}
.frame(minHeight: 28)
}
}
/// 行级右键菜单沿用目录工作流的命令与资格事实。
struct ServerNodeContextMenu: View {
let node: CatalogTreeNode
let workflow: CatalogWorkflow
let allowsSubscriptionManagement: Bool
/// 激活反馈共享状态:命令经它发出(单飞互斥、结果记录在会话反馈里)。
let activation: ActivationFeedbackState
let errors: ErrorAlertPresenter
let onEdit: (NodeID) -> Void
let onRename: (NodeID) -> Void
let onNewGroup: (NodeID?) -> Void
let onDuplicate: (NodeID) -> Void
let onDelete: (NodeID) -> Void
let onExport: (NodeID) -> Void
private var isSubscription: Bool { node.source == .subscription }
@ViewBuilder
var body: some View {
Button("激活") {
Task { @MainActor in
do {
let outcome = try await activation.activate(node.id, via: workflow)
// 右键菜单即关、无内联反馈面:原子拒绝点名弹窗(typed 原因随
// 结果返回);单飞忽略(nil)不呈现。
if case .rejectedActivation(let failure)? = outcome {
errors.present(failure)
}
} catch {
errors.present(error)
}
}
}
if let module = workflow.connectivity {
ConnectivityTestButton(module: module, node: node)
}
Divider()
if !isSubscription {
if node.isGroup {
Button("重命名…") { onRename(node.id) }
} else {
Button("编辑…") { onEdit(node.id) }
}
} else if allowsSubscriptionManagement, node.isRootSubscriptionGroup {
Button("编辑 URL…") { onEdit(node.id) }
}
Button {
onDuplicate(node.id)
} label: {
Label("复制", systemImage: "plus.square.on.square")
}
.disabled(!workflow.canDuplicate(node.id))
if !isSubscription {
Button("新建分组…") {
onNewGroup(node.isGroup ? node.id : node.parentID)
}
}
if node.isGroup {
Divider()
Button("导出为 SIP-008 JSON…") { onExport(node.id) }
.disabled(!node.containsServerConfiguration)
}
if !isSubscription || (allowsSubscriptionManagement && node.isRootSubscriptionGroup) {
Divider()
Button("删除…", role: .destructive) {
onDelete(node.id)
}
}
}
}