mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:14:51 +00:00
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.
114 lines
3.7 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|