feat: redesign home server activation tree

Preserve catalog hierarchy and order, separate selection from activation, and mark active targets and ancestors. Persist independent home expansion preferences and support explicit locate and keyboard navigation.

Show server names only and surface activation eligibility, skipped members, and failures. Refine row status visibility and use semantic selection colors for outlined activation buttons, including hover, pressed, disabled, contrast, and inactive-window states.

Validation: 695 hostless macOS arm64 tests passed for the tree implementation; 7 focused tests and ad-hoc app compilation passed after the semantic button refinement. Format and lint checks passed. Native UI interactions and theme changes remain unverified.
This commit is contained in:
Qiu Yuzhou
2026-10-01 19:03:48 +08:00
parent a7465154d2
commit 1c706aa65f
9 changed files with 670 additions and 200 deletions
@@ -1,8 +1,8 @@
import Combine
/// 目录树折叠状态(纯窗口呈现状态):组合根持有的长寿命对象。destination 切换
/// 会销毁分区子树里的 `@State`,折叠状态因此必须活在视图层级之外;服务器分区
/// 侧栏与首页目标树共用同一实例,一处收起、两处互见。语义=收起的分组身份集合,
/// 会销毁分区子树里的 `@State`,折叠状态因此必须活在视图层级之外;服务器管理分区
/// 侧栏专用,首页另行持久化自己的状态。语义=收起的分组身份集合,
/// 缺省全部展开;不落盘,进程重启回全展开基线。
@MainActor
final class CatalogExpansionState: ObservableObject {
@@ -0,0 +1,170 @@
import Combine
import Foundation
struct HomeServerTreeRow: Identifiable {
let node: CatalogTreeNode
let depth: Int
var id: NodeID { node.id }
}
/// 首页独立的浏览状态。选择与折叠不改变目录或运行时活动目标。
@MainActor
final class HomeServerListState: ObservableObject {
enum Navigation { case upward, downward, left, right }
@Published private(set) var tree = CatalogTreeSnapshot(roots: [])
@Published private(set) var selection: NodeID?
@Published private(set) var collapsedGroupIDs: Set<NodeID> = []
private var knownGroupIDs: Set<NodeID> = []
private var didInitializeSelection = false
private let defaults: UserDefaults
private static let expansionKey = "homeServerList.expansion"
private struct ExpansionPreferences: Codable {
let known: Set<NodeID>
let collapsed: Set<NodeID>
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: Self.expansionKey),
let saved = try? JSONDecoder().decode(ExpansionPreferences.self, from: data)
{
knownGroupIDs = saved.known
collapsedGroupIDs = saved.collapsed
}
}
var visibleRows: [HomeServerTreeRow] {
var rows: [HomeServerTreeRow] = []
func walk(_ nodes: [CatalogTreeNode], depth: Int) {
for node in nodes {
rows.append(HomeServerTreeRow(node: node, depth: depth))
if node.isGroup, !collapsedGroupIDs.contains(node.id) {
walk(node.childNodes, depth: depth + 1)
}
}
}
walk(tree.roots, depth: 0)
return rows
}
func update(tree: CatalogTreeSnapshot, activeTargetID: NodeID?) {
let previousTree = self.tree
self.tree = tree
var currentGroups: Set<NodeID> = []
func walk(_ nodes: [CatalogTreeNode], depth: Int) {
for node in nodes where node.isGroup {
currentGroups.insert(node.id)
if knownGroupIDs.insert(node.id).inserted, depth > 0 {
collapsedGroupIDs.insert(node.id)
}
walk(node.childNodes, depth: depth + 1)
}
}
walk(tree.roots, depth: 0)
knownGroupIDs.formIntersection(currentGroups)
collapsedGroupIDs.formIntersection(currentGroups)
if !didInitializeSelection, !tree.isEmpty {
selection = activeTargetID.flatMap { tree.containsNode($0) ? $0 : nil }
didInitializeSelection = true
} else if let selection, !tree.containsNode(selection) {
self.selection = nil
} else if let selection, tree != previousTree,
let visibleAncestor = ancestorIDs(for: selection).reversed().first(where: {
collapsedGroupIDs.contains($0)
})
{
self.selection = visibleAncestor
}
saveExpansion()
}
func select(_ id: NodeID) {
guard visibleRows.contains(where: { $0.id == id }) else { return }
selection = id
}
/// 行内按钮与 Return 共用门禁;隐藏、无效和已激活目标均不发出命令。
func activationTarget(
activeTargetID: NodeID?, eligibility: ActivationEligibility?
) -> NodeID? {
guard let selection, selection != activeTargetID,
eligibility?.canActivate == true,
visibleRows.contains(where: { $0.id == selection })
else { return nil }
return selection
}
/// 明确定位才展开祖先;启动时隐藏的活动项保持选中而不自动展开。
func locate(_ id: NodeID) {
guard tree.containsNode(id) else { return }
collapsedGroupIDs.subtract(ancestorIDs(for: id))
selection = id
saveExpansion()
}
func toggleGroup(_ id: NodeID) {
guard tree.node(withID: id)?.isGroup == true else { return }
if !collapsedGroupIDs.insert(id).inserted {
collapsedGroupIDs.remove(id)
} else if let selection, ancestorIDs(for: selection).contains(id) {
self.selection = id
}
saveExpansion()
}
func ancestorIDs(for id: NodeID) -> [NodeID] {
var result: [NodeID] = []
var parent = tree.node(withID: id)?.parentID
while let id = parent {
result.append(id)
parent = tree.node(withID: id)?.parentID
}
return result
}
func navigate(_ direction: Navigation) {
let rows = visibleRows
guard !rows.isEmpty else { return }
guard let selection, let index = rows.firstIndex(where: { $0.id == selection }) else {
if direction == .upward || direction == .downward {
self.selection = direction == .upward ? rows.last?.id : rows.first?.id
}
return
}
let node = rows[index].node
switch direction {
case .upward:
self.selection = rows[max(0, index - 1)].id
case .downward:
self.selection = rows[min(rows.count - 1, index + 1)].id
case .left:
collapseOrSelectParent(node)
case .right:
expandOrSelectChild(node)
}
}
private func collapseOrSelectParent(_ node: CatalogTreeNode) {
if node.isGroup, !collapsedGroupIDs.contains(node.id) {
toggleGroup(node.id)
} else if let parent = node.parentID {
selection = parent
}
}
private func expandOrSelectChild(_ node: CatalogTreeNode) {
if node.isGroup, collapsedGroupIDs.contains(node.id) {
toggleGroup(node.id)
} else if let child = node.childNodes.first {
selection = child.id
}
}
private func saveExpansion() {
let preferences = ExpansionPreferences(known: knownGroupIDs, collapsed: collapsedGroupIDs)
if let data = try? JSONEncoder().encode(preferences) {
defaults.set(data, forKey: Self.expansionKey)
}
}
}
@@ -0,0 +1,338 @@
import SwiftUI
/// 首页只浏览与激活;目录管理、运行控制仍由各自分区负责。
struct TargetTreeCard: View {
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var control: ProxyControlWorkflow
@ObservedObject var serverList: HomeServerListState
let onManageServers: () -> Void
@FocusState private var listFocused: Bool
@State private var activatingID: NodeID?
@State private var activationError: String?
@State private var activationRejected = false
@State private var activationSkippedCount = 0
private var activeID: NodeID? { control.snapshot.activeTarget?.id }
private static let navigationKeys: Set<KeyEquivalent> = [
.upArrow, .downArrow, .leftArrow, .rightArrow,
]
var body: some View {
HomeCard(
title: "服务器列表", subtitle: nil,
trailing: { EmptyView() },
content: {
ScrollViewReader { proxy in
VStack(alignment: .leading, spacing: 12) {
currentTarget {
guard let activeID else { return }
serverList.locate(activeID)
listFocused = true
// 展开后的行需要在下一轮布局中存在,才能滚动定位。
Task { @MainActor in
await Task.yield()
proxy.scrollTo(activeID, anchor: .center)
}
}
Divider()
feedback
if workflow.tree.isEmpty {
emptyState
} else {
tree
.onChange(of: serverList.selection) {
if let selection = serverList.selection,
serverList.visibleRows.contains(where: { $0.id == selection })
{
proxy.scrollTo(selection, anchor: .center)
}
}
}
}
.padding(.top, 12)
}
})
}
private func currentTarget(onLocate: @escaping () -> Void) -> some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
if let activeID {
Text("当前激活:\(workflow.tree.node(withID: activeID)?.name ?? "名称不可用")")
.font(.callout.weight(.semibold))
Text("路径:\(workflow.tree.pathSummary(for: activeID) ?? "路径不可用")")
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} else {
Text("未激活")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if let activeID {
Button("定位", action: onLocate)
.disabled(!workflow.tree.containsNode(activeID))
.help("展开并定位当前激活项")
}
}
}
@ViewBuilder
private var feedback: some View {
if let failureMessage {
Label(failureMessage, systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(.orange)
} else if activationSkippedCount > 0 {
Text("已跳过 \(activationSkippedCount) 个无效服务器")
.font(.callout)
.foregroundStyle(.secondary)
}
}
private var failureMessage: String? {
if let activationError { return activationError }
guard activationRejected else { return nil }
return control.snapshot.activationFailure.map { AppPresentation.message(for: $0) }
?? "激活失败:目标已失效或没有可激活的服务器"
}
private var emptyState: some View {
VStack(alignment: .leading, spacing: 10) {
Text("暂无服务器")
.foregroundStyle(.secondary)
Button("前往服务器管理", action: onManageServers)
}
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
}
private var tree: some View {
ScrollView {
LazyVStack(spacing: 2) {
ForEach(serverList.visibleRows) { row in
HomeServerListRow(
row: row,
eligibility: workflow.activationEligibility(for: row.id),
isSelected: serverList.selection == row.id,
isActive: activeID == row.id,
containsActive: activeID.map { serverList.ancestorIDs(for: $0).contains(row.id) }
?? false,
isCollapsed: serverList.collapsedGroupIDs.contains(row.id),
isActivationPending: activatingID == row.id,
activationBlocked: activatingID != nil,
onSelect: {
serverList.select(row.id)
listFocused = true
},
onToggle: { serverList.toggleGroup(row.id) },
onActivate: { activate(row.id) }
)
.id(row.id)
}
}
.padding(.vertical, 2)
}
.frame(height: 340)
.focusable(interactions: .edit)
.focused($listFocused)
.accessibilityLabel("服务器列表")
.onKeyPress(keys: Self.navigationKeys, phases: [.down, .repeat]) { press in
switch press.key {
case .upArrow: serverList.navigate(.upward)
case .downArrow: serverList.navigate(.downward)
case .leftArrow: serverList.navigate(.left)
case .rightArrow: serverList.navigate(.right)
default: return .ignored
}
return .handled
}
.onKeyPress(.return, phases: .down) { _ in
if let selection = serverList.selection { activate(selection) }
return .handled
}
}
private func activate(_ id: NodeID) {
guard activatingID == nil,
serverList.activationTarget(
activeTargetID: activeID, eligibility: workflow.activationEligibility(for: id)) == id
else { return }
activatingID = id
activationError = nil
activationRejected = false
activationSkippedCount = 0
Task { @MainActor in
defer { activatingID = nil }
do {
switch try await workflow.activate(id) {
case .activated(let skipped):
activationSkippedCount = skipped
case .rejectedActivation:
activationRejected = true
}
} catch {
activationError = AppPresentation.message(for: error)
}
}
}
}
private struct HomeServerListRow: View {
@Environment(\.controlActiveState) private var controlActiveState
let row: HomeServerTreeRow
let eligibility: ActivationEligibility?
let isSelected: Bool
let isActive: Bool
let containsActive: Bool
let isCollapsed: Bool
let isActivationPending: Bool
let activationBlocked: Bool
let onSelect: () -> Void
let onToggle: () -> Void
let onActivate: () -> Void
private var node: CatalogTreeNode { row.node }
private var selectionTextColor: Color {
Color(
nsColor: controlActiveState == .inactive
? .unemphasizedSelectedTextColor : .alternateSelectedControlTextColor)
}
private var selectionBackgroundColor: Color {
Color(
nsColor: controlActiveState == .inactive
? .unemphasizedSelectedContentBackgroundColor : .selectedContentBackgroundColor)
}
private var textColor: Color {
isSelected ? selectionTextColor : .primary
}
var body: some View {
HStack(spacing: 8) {
if node.isGroup {
Button(action: onToggle) {
Image(systemName: "chevron.right")
.rotationEffect(.degrees(isCollapsed ? 0 : 90))
.frame(width: 16, height: 24)
}
.buttonStyle(.plain)
.focusable(false)
.accessibilityLabel("\(isCollapsed ? "展开" : "收起") \(node.name)")
} else {
Color.clear.frame(width: 16, height: 24)
}
Button(action: onSelect) {
HStack(spacing: 8) {
Image(systemName: node.isGroup ? "folder" : "server.rack")
VStack(alignment: .leading, spacing: 3) {
Text(node.name)
.font(.body.weight(isActive ? .semibold : .regular))
.lineLimit(1)
.help(node.name)
if let eligibility, node.isGroup {
Text(groupSummary(eligibility))
.font(.caption)
}
if let reason = ineligibilityReason {
Text(reason)
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, minHeight: 30, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.focusable(false)
.accessibilityAddTraits(isSelected ? .isSelected : [])
if isActive || (containsActive && !isSelected) {
Image(systemName: isActive ? "bolt.fill" : "bolt")
.foregroundStyle(isSelected ? textColor : .orange)
.frame(width: 16)
.help(isActive ? "当前激活项" : "包含当前激活项")
.accessibilityLabel(isActive ? "当前激活项" : "包含当前激活项")
}
// 保留当前选中行的操作区宽度。
if !isActive && isSelected {
Group {
Button(isActivationPending ? "激活中…" : "激活", action: onActivate)
.buttonStyle(SelectionActivationButtonStyle(foreground: selectionTextColor))
.controlSize(.small)
.disabled(activationBlocked || eligibility?.canActivate != true)
.help(ineligibilityReason ?? "激活此项")
.allowsHitTesting(isSelected)
}
.frame(width: 76, alignment: .trailing)
}
}
.foregroundStyle(textColor)
.padding(.leading, CGFloat(row.depth) * 18 + 8)
.padding(.trailing, 8)
.padding(.vertical, 6)
.background(
isSelected ? selectionBackgroundColor : .clear,
in: RoundedRectangle(cornerRadius: 6))
}
private func groupSummary(_ eligibility: ActivationEligibility) -> String {
let count = "\(eligibility.candidateCount) 个可激活服务器"
return eligibility.skippedInvalidCount > 0
? "\(count) · 跳过 \(eligibility.skippedInvalidCount) 个" : count
}
private var ineligibilityReason: String? {
if let reason = node.invalidReasons.first {
return AppPresentation.message(
for: ActivationFailure.invalidLeaf(node: node.id, reason: reason))
}
switch eligibility?.ineligibility {
case .emptyGroup: return "空组,无法激活"
case .noCandidates: return "没有可激活的有效服务器"
case nil: return nil
}
}
}
/// 按钮与所在选中行共用语义前景色;不叠加系统按钮的着色背景。
private struct SelectionActivationButtonStyle: ButtonStyle {
let foreground: Color
func makeBody(configuration: Configuration) -> some View {
SelectionActivationButtonContent(configuration: configuration, foreground: foreground)
}
}
private struct SelectionActivationButtonContent: View {
let configuration: ButtonStyle.Configuration
let foreground: Color
@Environment(\.isEnabled) private var isEnabled
@Environment(\.colorSchemeContrast) private var contrast
@State private var isHovered = false
private var backgroundOpacity: Double {
guard isEnabled else { return 0 }
if configuration.isPressed { return 0.20 }
return isHovered ? 0.10 : 0
}
var body: some View {
configuration.label
.font(.callout.weight(.medium))
.foregroundStyle(foreground.opacity(isEnabled ? 1 : 0.45))
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(foreground.opacity(backgroundOpacity), in: RoundedRectangle(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.strokeBorder(
foreground.opacity(isEnabled ? (contrast == .increased ? 0.8 : 0.45) : 0.20),
lineWidth: contrast == .increased ? 2 : 1)
}
.contentShape(RoundedRectangle(cornerRadius: 6))
.onHover { isHovered = $0 }
}
}
-188
View File
@@ -1,186 +1,5 @@
import SwiftUI
/// 「当前服务器」卡:只显示有服务器的分组;双击节点激活为当前服务器。
struct TargetTreeCard: View {
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var control: ProxyControlWorkflow
/// 分组折叠状态:组合根持有的共享对象,与服务器分区侧栏同源,跨分区切换存续。
@ObservedObject var expansion: CatalogExpansionState
let onManageServers: () -> Void
let errors: ErrorAlertPresenter
var body: some View {
HomeCard(
title: "当前服务器",
subtitle: "只显示有服务器的分组,双击节点以设为当前服务器",
trailing: {
Button(action: onManageServers) {
HStack(spacing: 4) {
Text("管理服务器")
Image(systemName: "arrow.right")
}
.font(.callout.weight(.medium))
}
.buttonStyle(.plain)
.foregroundStyle(.tint)
},
content: {
tree.padding(.top, 6)
})
}
@ViewBuilder
private var tree: some View {
let groups = targetGroups
if groups.isEmpty {
Text("暂无服务器;在「服务器」分区导入 ss:// 链接或新建分组。")
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
} else {
VStack(alignment: .leading, spacing: 4) {
ForEach(groups) { group in
TargetGroupSection(
group: group,
activeTargetID: control.snapshot.activeTarget?.id,
isCollapsed: expansion.isCollapsed(group.id),
onToggle: { expansion.toggleCollapsed(group.id) },
onActivate: activate)
}
ForEach(rootServers) { server in
TargetServerRow(
server: server,
isActive: control.snapshot.activeTarget?.id == server.id,
onActivate: activate)
}
}
}
}
private func activate(_ id: NodeID) {
Task {
do {
_ = try await workflow.activate(id)
} catch {
errors.present(error)
}
}
}
/// 有服务器叶子的分组(递归收集;每组只呈现直属服务器叶子)。
private var targetGroups: [TargetGroupInfo] {
var groups: [TargetGroupInfo] = []
func walk(_ nodes: [CatalogTreeNode]) {
for node in nodes where node.isGroup {
let servers = node.childNodes.filter { !$0.isGroup }
if !servers.isEmpty {
groups.append(
TargetGroupInfo(
id: node.id,
name: node.name,
servers: servers))
}
walk(node.childNodes.filter { $0.isGroup })
}
}
walk(workflow.tree.roots)
return groups
}
/// 根层直接挂的服务器叶子(目录根层允许放服务器时兜底呈现)。
private var rootServers: [CatalogTreeNode] {
workflow.tree.roots.filter { !$0.isGroup }
}
}
/// 单个目标分组:展开/收起头 + 直属服务器行。
struct TargetGroupSection: View {
let group: TargetGroupInfo
let activeTargetID: NodeID?
let isCollapsed: Bool
let onToggle: () -> Void
let onActivate: (NodeID) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Button(action: onToggle) {
HStack(spacing: 8) {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(isCollapsed ? 0 : 90))
Image(systemName: "folder")
.foregroundStyle(.secondary)
Text(group.name)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if !isCollapsed {
VStack(alignment: .leading, spacing: 2) {
ForEach(group.servers) { server in
TargetServerRow(
server: server,
isActive: activeTargetID == server.id,
onActivate: onActivate)
}
}
.padding(.leading, 16)
}
}
.padding(.vertical, 2)
}
}
/// 目标树里的服务器行:双击激活,活动目标勾选标记。
struct TargetServerRow: View {
let server: CatalogTreeNode
let isActive: Bool
let onActivate: (NodeID) -> Void
var body: some View {
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(.quaternary)
.frame(width: 28, height: 28)
.overlay {
Image(systemName: "server.rack")
.font(.system(size: 12))
.foregroundStyle(.tint)
}
Text(server.name)
.font(.body.weight(isActive ? .semibold : .regular))
.foregroundStyle(server.isInvalid ? .secondary : .primary)
Spacer(minLength: 0)
if server.isInvalid {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.help("该服务器存在已知阻塞问题,激活会被点名拒绝")
}
Image(systemName: "checkmark")
.font(.callout.weight(.semibold))
.foregroundStyle(.tint)
.opacity(isActive ? 1 : 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
isActive ? Color.accentColor.opacity(0.10) : Color.clear,
in: RoundedRectangle(cornerRadius: 8, style: .continuous)
)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
onActivate(server.id)
}
.help("双击设为当前服务器")
}
}
/// 「复制代理环境变量设置命令」卡:按所选 shell 格式复制 HTTP 与 SOCKS 环境变量。
/// 非「仅本机」监听方式下先以单选下拉框选择命令地址(issue #72),两种 shell
/// 的全部代理端点共用该选择;仅本机方式隐藏下拉框并始终使用回环地址。
@@ -366,10 +185,3 @@ struct QuickActionCard: View {
}
}
/// 目标分组的呈现模型(票 #54):名称与直属服务器叶子。
struct TargetGroupInfo: Identifiable {
let id: NodeID
let name: String
let servers: [CatalogTreeNode]
}
+3 -4
View File
@@ -7,7 +7,7 @@ import SwiftUI
struct HomeView: View {
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var control: ProxyControlWorkflow
let expansion: CatalogExpansionState
let serverList: HomeServerListState
let clipboard: any TextClipboard
let onManageServers: () -> Void
let errors: ErrorAlertPresenter
@@ -20,9 +20,8 @@ struct HomeView: View {
TargetTreeCard(
workflow: workflow,
control: control,
expansion: expansion,
onManageServers: onManageServers,
errors: errors)
serverList: serverList,
onManageServers: onManageServers)
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 20) {
+6 -2
View File
@@ -36,12 +36,13 @@ struct MainWindowView: View {
@ObservedObject var loginController: LaunchAtLoginController
@ObservedObject var silentLaunch: SilentLaunchController
/// 目录树折叠状态:组合根持有的长寿命对象,跨 destination 切换存续;服务器
/// 侧栏与首页目标树共用(见 CatalogExpansionState)。
/// 侧栏使用(见 CatalogExpansionState);首页独立保存浏览状态。
@ObservedObject var expansion: CatalogExpansionState
let clipboard: any TextClipboard
let diagnosticReportExporter: any DiagnosticReportExporter
let configurationGroupFileExporter: any ConfigurationGroupFileExporter
@StateObject private var homeServerList = HomeServerListState()
@State private var selection: NodeID?
/// 全局添加菜单打开的表单,以及诊断导出由窗口壳持有。
@State private var presentedWorkspaceSheet: WorkspaceSheet?
@@ -60,6 +61,9 @@ struct MainWindowView: View {
} detail: {
destinationView
}
.onChange(of: workflow.tree, initial: true) {
homeServerList.update(tree: workflow.tree, activeTargetID: control.snapshot.activeTarget?.id)
}
.navigationTitle(route.destination.label)
.frame(minWidth: 920, minHeight: 580)
.toolbar {
@@ -186,7 +190,7 @@ struct MainWindowView: View {
WorkspaceHomeView(
workflow: workflow,
control: control,
expansion: expansion,
serverList: homeServerList,
clipboard: clipboard,
onManageServers: { route.navigate(to: .servers) })
case .servers:
@@ -6,7 +6,7 @@ import SwiftUI
struct WorkspaceHomeView: View {
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var control: ProxyControlWorkflow
let expansion: CatalogExpansionState
let serverList: HomeServerListState
let clipboard: any TextClipboard
let onManageServers: () -> Void
@StateObject private var errors = ErrorAlertPresenter()
@@ -18,7 +18,7 @@ struct WorkspaceHomeView: View {
HomeView(
workflow: workflow,
control: control,
expansion: expansion,
serverList: serverList,
clipboard: clipboard,
onManageServers: onManageServers,
errors: errors
@@ -23,8 +23,7 @@ final class CatalogExpansionStateTests: XCTestCase {
XCTAssertFalse(expansion.isCollapsed(id))
}
/// 两处服务器树(服务器侧栏/首页目标树)共用同一实例:同一分组身份在两处
/// 读到同一折叠事实,互不覆盖。
/// 服务器管理树的各组折叠事实按身份独立保存。
func testCollapseSetIsKeyedPerGroupID() {
let expansion = CatalogExpansionState()
let first = NodeID(rawValue: "g-first")
@@ -0,0 +1,148 @@
import XCTest
@testable import ShadowsocksX_NG2
@MainActor
final class HomeServerListStateTests: XCTestCase {
private var defaults: UserDefaults!
private var suiteName: String!
override func setUp() async throws {
suiteName = "HomeServerListStateTests.\(UUID().uuidString)"
defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
}
override func tearDown() async throws {
defaults.removePersistentDomain(forName: suiteName)
defaults = nil
}
func testTreePreservesRootOrderAndPureContainerGroups() {
let state = HomeServerListState(defaults: defaults)
let tree = fixtureTree()
state.update(tree: tree, activeTargetID: nil)
XCTAssertEqual(
state.visibleRows.map(\.id.rawValue), ["root-server", "outer", "inner", "empty"])
XCTAssertEqual(state.visibleRows.map(\.depth), [0, 0, 1, 0])
state.toggleGroup(id("inner"))
XCTAssertEqual(
state.visibleRows.map(\.id.rawValue),
["root-server", "outer", "inner", "leaf", "invalid", "empty"])
XCTAssertNil(state.selection)
}
private func id(_ value: String) -> NodeID { NodeID(rawValue: value) }
func testExpansionRestoresAcrossRestartWithoutAffectingManagement() {
let state = HomeServerListState(defaults: defaults)
state.update(tree: fixtureTree(), activeTargetID: nil)
state.toggleGroup(id("inner"))
state.toggleGroup(id("outer"))
let restarted = HomeServerListState(defaults: defaults)
restarted.update(tree: fixtureTree(), activeTargetID: id("leaf"))
XCTAssertEqual(restarted.visibleRows.map(\.id.rawValue), ["root-server", "outer", "empty"])
restarted.toggleGroup(id("outer"))
XCTAssertEqual(
restarted.visibleRows.map(\.id.rawValue),
["root-server", "outer", "inner", "leaf", "invalid", "empty"])
XCTAssertFalse(CatalogExpansionState().isCollapsed(id("outer")))
}
private func fixtureTree() -> CatalogTreeSnapshot {
let leaf = node("leaf", parent: "inner")
let invalid = node("invalid", parent: "inner", invalid: true)
let inner = node("inner", parent: "outer", children: [leaf, invalid])
return CatalogTreeSnapshot(roots: [
node("root-server"), node("outer", children: [inner]), node("empty", children: []),
])
}
func testSelectionAndLocateDoNotImplicitlyFollowActivationChanges() {
let state = HomeServerListState(defaults: defaults)
state.update(tree: fixtureTree(), activeTargetID: id("leaf"))
XCTAssertEqual(state.selection, id("leaf"))
XCTAssertFalse(state.visibleRows.map(\.id).contains(id("leaf")))
state.select(id("root-server"))
state.update(tree: fixtureTree(), activeTargetID: id("inner"))
XCTAssertEqual(state.selection, id("root-server"))
state.toggleGroup(id("outer"))
state.locate(id("leaf"))
XCTAssertEqual(state.selection, id("leaf"))
XCTAssertTrue(state.visibleRows.map(\.id).contains(id("leaf")))
state.toggleGroup(id("inner"))
XCTAssertEqual(state.selection, id("inner"))
state.update(tree: CatalogTreeSnapshot(roots: [node("root-server")]), activeTargetID: nil)
XCTAssertNil(state.selection)
}
private func node(
_ value: String, parent: String? = nil, children: [CatalogTreeNode]? = nil,
invalid: Bool = false
) -> CatalogTreeNode {
CatalogTreeNode(
id: id(value), name: value, isGroup: children != nil, source: .manual,
parentID: parent.map(id),
invalidReasons: invalid ? [.unsupportedEncryptionMethod("future-cipher")] : [],
childCount: children?.count ?? 0,
subtreeNodeCount: children?.reduce(0) { $0 + 1 + $1.subtreeNodeCount } ?? 0,
invalidDescendantCount: children?.reduce(0) { $0 + $1.subtreeInvalidServerCount } ?? 0,
children: children)
}
func testKeyboardNavigationUsesVisibleRowsAndTreeRelationships() {
let state = HomeServerListState(defaults: defaults)
state.update(tree: fixtureTree(), activeTargetID: nil)
state.navigate(.downward)
XCTAssertEqual(state.selection, id("root-server"))
state.navigate(.downward)
state.navigate(.right)
XCTAssertEqual(state.selection, id("inner"))
state.navigate(.right)
XCTAssertEqual(state.selection, id("inner"), "首次右键只展开")
state.navigate(.right)
XCTAssertEqual(state.selection, id("leaf"))
state.navigate(.left)
XCTAssertEqual(state.selection, id("inner"))
state.navigate(.left)
state.navigate(.downward)
XCTAssertEqual(state.selection, id("empty"), "跳过隐藏成员")
state.navigate(.downward)
XCTAssertEqual(state.selection, id("empty"))
state.navigate(.upward)
XCTAssertEqual(state.selection, id("inner"))
}
func testOnlySelectedVisibleEligibleNonActiveTargetCanBeActivated() {
let state = HomeServerListState(defaults: defaults)
state.update(tree: fixtureTree(), activeTargetID: id("leaf"))
let eligible = ActivationEligibility(
canActivate: true, candidateCount: 1, skippedInvalidCount: 0, ineligibility: nil)
XCTAssertNil(state.activationTarget(activeTargetID: nil, eligibility: eligible))
state.select(id("root-server"))
XCTAssertEqual(
state.activationTarget(activeTargetID: id("outer"), eligibility: eligible), id("root-server"))
XCTAssertNil(state.activationTarget(activeTargetID: id("root-server"), eligibility: eligible))
let unavailable = ActivationEligibility(
canActivate: false, candidateCount: 0, skippedInvalidCount: 1, ineligibility: .noCandidates)
XCTAssertNil(state.activationTarget(activeTargetID: nil, eligibility: unavailable))
XCTAssertNil(state.activationTarget(activeTargetID: nil, eligibility: nil))
}
func testMovedSelectionFollowsVisibleAncestorAndActiveMarksKeepTargetIdentity() {
let state = HomeServerListState(defaults: defaults)
state.update(tree: fixtureTree(), activeTargetID: nil)
state.select(id("root-server"))
let moved = node("root-server", parent: "inner")
let inner = node("inner", parent: "outer", children: [moved])
state.update(
tree: CatalogTreeSnapshot(roots: [node("outer", children: [inner])]),
activeTargetID: id("inner"))
XCTAssertEqual(state.selection, id("inner"))
XCTAssertEqual(state.ancestorIDs(for: id("inner")), [id("outer")])
XCTAssertEqual(state.ancestorIDs(for: id("root-server")), [id("inner"), id("outer")])
state.locate(id("inner"))
XCTAssertFalse(state.visibleRows.map(\.id).contains(id("root-server")), "定位组不展开组自身")
}
}