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")), "定位组不展开组自身") + } +}