From 1c706aa65feb3fba18a7948203e44fb81883c14d Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Thu, 1 Oct 2026 17:23:24 +0800 Subject: [PATCH] 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. --- .../App/CatalogExpansionState.swift | 4 +- .../App/HomeServerListState.swift | 170 +++++++++ ShadowsocksX-NG2/App/HomeServerListView.swift | 338 ++++++++++++++++++ ShadowsocksX-NG2/App/HomeView+Cards.swift | 188 ---------- ShadowsocksX-NG2/App/HomeView.swift | 7 +- ShadowsocksX-NG2/App/MainWindowView.swift | 8 +- .../App/WorkspaceDestinations.swift | 4 +- .../Tests/CatalogExpansionStateTests.swift | 3 +- .../Tests/HomeServerListStateTests.swift | 148 ++++++++ 9 files changed, 670 insertions(+), 200 deletions(-) create mode 100644 ShadowsocksX-NG2/App/HomeServerListState.swift create mode 100644 ShadowsocksX-NG2/App/HomeServerListView.swift create mode 100644 ShadowsocksX-NG2/Tests/HomeServerListStateTests.swift diff --git a/ShadowsocksX-NG2/App/CatalogExpansionState.swift b/ShadowsocksX-NG2/App/CatalogExpansionState.swift index 228082d9..7676045a 100644 --- a/ShadowsocksX-NG2/App/CatalogExpansionState.swift +++ b/ShadowsocksX-NG2/App/CatalogExpansionState.swift @@ -1,8 +1,8 @@ import Combine /// 目录树折叠状态(纯窗口呈现状态):组合根持有的长寿命对象。destination 切换 -/// 会销毁分区子树里的 `@State`,折叠状态因此必须活在视图层级之外;服务器分区 -/// 侧栏与首页目标树共用同一实例,一处收起、两处互见。语义=收起的分组身份集合, +/// 会销毁分区子树里的 `@State`,折叠状态因此必须活在视图层级之外;服务器管理分区 +/// 侧栏专用,首页另行持久化自己的状态。语义=收起的分组身份集合, /// 缺省全部展开;不落盘,进程重启回全展开基线。 @MainActor final class CatalogExpansionState: ObservableObject { diff --git a/ShadowsocksX-NG2/App/HomeServerListState.swift b/ShadowsocksX-NG2/App/HomeServerListState.swift new file mode 100644 index 00000000..a4f37eab --- /dev/null +++ b/ShadowsocksX-NG2/App/HomeServerListState.swift @@ -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 = [] + private var knownGroupIDs: Set = [] + private var didInitializeSelection = false + private let defaults: UserDefaults + private static let expansionKey = "homeServerList.expansion" + + private struct ExpansionPreferences: Codable { + let known: Set + let collapsed: Set + } + + 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 = [] + 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) + } + } +} diff --git a/ShadowsocksX-NG2/App/HomeServerListView.swift b/ShadowsocksX-NG2/App/HomeServerListView.swift new file mode 100644 index 00000000..9a69dfb8 --- /dev/null +++ b/ShadowsocksX-NG2/App/HomeServerListView.swift @@ -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 = [ + .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 } + } +} diff --git a/ShadowsocksX-NG2/App/HomeView+Cards.swift b/ShadowsocksX-NG2/App/HomeView+Cards.swift index a57e15d7..236707a7 100644 --- a/ShadowsocksX-NG2/App/HomeView+Cards.swift +++ b/ShadowsocksX-NG2/App/HomeView+Cards.swift @@ -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] -} diff --git a/ShadowsocksX-NG2/App/HomeView.swift b/ShadowsocksX-NG2/App/HomeView.swift index e170000a..3dfae0d1 100644 --- a/ShadowsocksX-NG2/App/HomeView.swift +++ b/ShadowsocksX-NG2/App/HomeView.swift @@ -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) { diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index a71b6714..138138f1 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -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: diff --git a/ShadowsocksX-NG2/App/WorkspaceDestinations.swift b/ShadowsocksX-NG2/App/WorkspaceDestinations.swift index 93ef07fd..f3c413bf 100644 --- a/ShadowsocksX-NG2/App/WorkspaceDestinations.swift +++ b/ShadowsocksX-NG2/App/WorkspaceDestinations.swift @@ -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 diff --git a/ShadowsocksX-NG2/Tests/CatalogExpansionStateTests.swift b/ShadowsocksX-NG2/Tests/CatalogExpansionStateTests.swift index 06d6c495..5add341f 100644 --- a/ShadowsocksX-NG2/Tests/CatalogExpansionStateTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogExpansionStateTests.swift @@ -23,8 +23,7 @@ final class CatalogExpansionStateTests: XCTestCase { XCTAssertFalse(expansion.isCollapsed(id)) } - /// 两处服务器树(服务器侧栏/首页目标树)共用同一实例:同一分组身份在两处 - /// 读到同一折叠事实,互不覆盖。 + /// 服务器管理树的各组折叠事实按身份独立保存。 func testCollapseSetIsKeyedPerGroupID() { let expansion = CatalogExpansionState() let first = NodeID(rawValue: "g-first") diff --git a/ShadowsocksX-NG2/Tests/HomeServerListStateTests.swift b/ShadowsocksX-NG2/Tests/HomeServerListStateTests.swift new file mode 100644 index 00000000..237b769e --- /dev/null +++ b/ShadowsocksX-NG2/Tests/HomeServerListStateTests.swift @@ -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")), "定位组不展开组自身") + } +}