From f7f608b161534b5bcceed890395cfdbcaa2092da Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Tue, 6 Oct 2026 14:44:17 +0800 Subject: [PATCH] refactor(ui): replace workspace sidebar with tabs --- .../App/Composition/MainApp.swift | 5 +- .../App/UI/StatusMenu/ProxyStatusMenu.swift | 170 ++++-------------- .../App/UI/Workspace/MainWindowView.swift | 152 +++------------- .../App/UI/Workspace/StatusCardModel.swift | 2 +- .../App/UI/Workspace/StatusCardView.swift | 2 +- .../App/UI/Workspace/WorkspaceRoute.swift | 8 +- 6 files changed, 71 insertions(+), 268 deletions(-) diff --git a/ShadowsocksX-NG2/App/Composition/MainApp.swift b/ShadowsocksX-NG2/App/Composition/MainApp.swift index cc6afc0c..f4533f42 100644 --- a/ShadowsocksX-NG2/App/Composition/MainApp.swift +++ b/ShadowsocksX-NG2/App/Composition/MainApp.swift @@ -48,12 +48,11 @@ struct ShadowsocksXNG2App: App { var body: some Scene { MenuBarExtra { ProxyStatusMenu( - control: proxyControl, - catalogWorkflow: catalogWorkflow) + control: proxyControl) } label: { StatusMenuLabel(control: proxyControl) } - .menuBarExtraStyle(.menu) + .menuBarExtraStyle(.window) // 主 workspace 窗口(ADR 0016/0017):SwiftUI `Window` scene。启动呈现由 // defaultLaunchBehavior 决定:静默启动关闭(默认)时 presented 启动即呈现 diff --git a/ShadowsocksX-NG2/App/UI/StatusMenu/ProxyStatusMenu.swift b/ShadowsocksX-NG2/App/UI/StatusMenu/ProxyStatusMenu.swift index 1d615aae..86dcd1c8 100644 --- a/ShadowsocksX-NG2/App/UI/StatusMenu/ProxyStatusMenu.swift +++ b/ShadowsocksX-NG2/App/UI/StatusMenu/ProxyStatusMenu.swift @@ -1,152 +1,50 @@ import AppKit import SwiftUI -/// 状态菜单(spec #21 D11 白名单收敛为六项,issue #31/#41/#47/#60): -/// ①打开主窗口(置顶单独区域)②头部状态摘要(agent 运行状态、当前模式、 -/// 活动目标、系统代理实际应用)③两个开关(后台代理 agent 与系统代理,互不 -/// 代替)④模式选择(勾选态)⑤活动目标级联选择器(组树子菜单、只读) -/// ⑥退出(明示代理仍在后台运行)。白名单外操作一律不进菜单栏;编辑类操作 -/// 只在主 workspace。运行时事实、开关与模式全部来自代理控制工作流的整体 -/// snapshot(issue #47/#60),菜单不直接读控制器字段;目录树与激活动作仍走 -/// 目录工作流。 +/// 菜单栏窗口式面板:重开主窗口、只读状态卡、无效服务器提示与退出。 +/// 控制与目标选择留在工作区;状态卡继续从 control 的整体 snapshot 派生。 struct ProxyStatusMenu: View { - /// 代理控制唯一 seam(issue #47):状态摘要与代理命令的唯一来源。 @ObservedObject var control: ProxyControlWorkflow - @ObservedObject var catalogWorkflow: CatalogWorkflow - /// 主窗口是 SwiftUI `Window` scene(见 MainApp):启动呈现由场景的 - /// defaultLaunchBehavior 负责,状态菜单是关窗后的重开入口(ADR 0016); - /// scene id 经 WorkspaceRoute 单点引用。 @Environment(\.openWindow) private var openWindow + /// 只捕获本面板的宿主窗口,打开主窗口时不会误关其他窗口。 + @State private var windowAnchor = NSView(frame: .zero) var body: some View { - let snapshot = control.snapshot - let summary = StatusMenuModel.summary(from: snapshot) - let targetTree = catalogWorkflow.tree.roots - - // ① 重开主窗口(置顶单独区域):启动已由 defaultLaunchBehavior(.presented) - // 呈现,本项服务关窗后的重开。accessory 形态下 SwiftUI 开窗不会自行抢 - // 焦点,先激活本 app 让窗口压过当前前台应用。 - Button("打开主窗口…") { - NSApp.activate(ignoringOtherApps: true) - openWindow(id: WorkspaceRoute.workspaceSceneID) - } - - Divider() - - // ② 头部状态摘要(agent 运行状态与系统代理实际应用分开呈现,issue #60) - Text(summary.status) - Text("模式:\(summary.modeLabel)") - Text(summary.targetPath.map { "目标:\($0)" } ?? "目标:未激活") - if let detail = summary.detail { - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - } - Text(summary.systemProxyStatus) - if let systemProxyDetail = summary.systemProxyDetail { - Text(systemProxyDetail) - .font(.caption) - .foregroundStyle(.secondary) - } - if snapshot.skippedInvalidServerCount > 0 { - Text("已跳过 \(snapshot.skippedInvalidServerCount) 个无效服务器") - .font(.caption) - .foregroundStyle(.orange) - .help("激活时跳过了存在已知本地阻塞问题的服务器") - } - - Divider() - - // ③ 两个开关(issue #60):agent 与系统代理意图互不代替,各绑定持久化 - // 意图;agent 关闭会保留选择,系统代理关闭会清理匹配 NG2 端点的设置。 - Toggle("后台代理", isOn: agentToggleBinding) - Toggle("设置系统代理", isOn: systemProxyToggleBinding) - - // ④ 模式选择(勾选态):可选性与顺序来自 snapshot 的 Domain 单点策略。 - Picker("模式", selection: modeBinding) { - ForEach(snapshot.availableModes, id: \.self) { mode in - Text(mode.label).tag(mode) + VStack(alignment: .leading, spacing: 0) { + Button("打开主窗口…") { + windowAnchor.window?.orderOut(nil) + NSApp.activate(ignoringOtherApps: true) + openWindow(id: WorkspaceRoute.workspaceSceneID) } - } - .pickerStyle(.inline) + .padding(12) - // 规则模式子选项(issue #63):仅规则模式呈现。 - if snapshot.proxyMode == .rule { - Picker("未匹配规则时", selection: ruleDefaultActionBinding) { - ForEach(RuleDefaultAction.allCases, id: \.self) { action in - Text(action.label).tag(action) - } + Divider() + StatusCardView(control: control) + + if control.snapshot.skippedInvalidServerCount > 0 { + Text("已跳过 \(control.snapshot.skippedInvalidServerCount) 个无效服务器") + .font(.caption) + .foregroundStyle(.orange) + .help("激活时跳过了存在已知本地阻塞问题的服务器") + .padding(.horizontal, 12) + .padding(.bottom, 12) } - .pickerStyle(.inline) - } - // ⑤ 活动目标级联(只读) - Menu("活动目标") { - if targetTree.isEmpty { - Text("目录为空") - } else { - TargetCascade(nodes: targetTree, activeTargetID: snapshot.activeTarget?.id) - } - } - - Divider() - - // ⑥ 退出:仅退 GUI;agent 由 launchd 持有,代理不受影响(构造上成立)。 - Button("退出 ShadowsocksX-NG2(代理仍在后台运行)") { - NSApp.terminate(nil) - } - } - - /// 模式选择走同一控制 seam:切换语义与菜单勾选态由同一 snapshot 事实来源 - /// 保证一致(issue #47)。 - private var modeBinding: Binding { - Binding( - get: { control.snapshot.proxyMode }, - set: { mode in Task { await control.setProxyMode(mode) } }) - } - - /// 规则模式子选项(issue #63):与首页共用同一控制 seam。 - private var ruleDefaultActionBinding: Binding { - Binding( - get: { control.snapshot.ruleDefaultAction }, - set: { action in Task { await control.setRuleDefaultAction(action) } }) - } - - /// 两个开关绑定持久化意图(issue #60);命令完成由 workflow 整体重发布。 - private var agentToggleBinding: Binding { - Binding( - get: { control.snapshot.agentIntentEnabled }, - set: { enabled in Task { await control.setAgentEnabled(enabled) } }) - } - - private var systemProxyToggleBinding: Binding { - Binding( - get: { control.snapshot.systemProxyIntentEnabled }, - set: { enabled in Task { await control.setSystemProxyEnabled(enabled) } }) - } - - /// 只读级联树:分组展开为子菜单,活动目标以勾选呈现;无编辑入口。 - /// 递归经由具名 View 类型展开(opaque 自引用无法编译)。 - private struct TargetCascade: View { - let nodes: [CatalogTreeNode] - let activeTargetID: NodeID? - - var body: some View { - ForEach(nodes, id: \.id) { node in - if node.isGroup { - Menu(node.name) { - if node.childNodes.isEmpty { - Text("空分组") - } else { - TargetCascade(nodes: node.childNodes, activeTargetID: activeTargetID) - } - } - } else if node.id == activeTargetID { - Text("✓ \(node.name)") - } else { - Text(node.name) - } + Divider() + Button("退出 ShadowsocksX-NG2(代理仍在后台运行)") { + NSApp.terminate(nil) } + .padding(12) } + .frame(width: 320, alignment: .leading) + .background(StatusPanelWindowAnchor(view: windowAnchor).frame(width: 0, height: 0)) } } + +/// MenuBarExtra 没有关闭弹出面板的绑定;零尺寸视图定位当前面板的窗口。 +private struct StatusPanelWindowAnchor: NSViewRepresentable { + let view: NSView + + func makeNSView(context: Context) -> NSView { view } + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift b/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift index 41a277d4..5943506d 100644 --- a/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift @@ -1,6 +1,6 @@ import SwiftUI -/// 服务器导入面板:由边栏工具栏「导入服务器配置」按钮打开,窗口壳统一持有 +/// 服务器导入面板:由窗口工具栏「导入服务器配置」按钮打开,窗口壳统一持有 /// 呈现;添加订阅表单由订阅分区自持(SubscriptionsView)。 private enum WorkspaceSheet: String, Identifiable { case importServers @@ -8,16 +8,9 @@ private enum WorkspaceSheet: String, Identifiable { var id: String { rawValue } } -/// 主窗口外壳(地图 #52,票 #53):NavigationSplitView 侧栏承载五项导航与 -/// 底部常驻代理状态卡;详情区按 route destination 承载各分区视图。分区名即 -/// 窗口标题(navigationTitle 绑定 route destination,ADR 0016 的 scene 版 -/// 窗口原生呈现);页级动作由各分区视图自挂 toolbar(呈现于窗口工具栏右 -/// 端,与服务器分区同法,票 #56/#58 的槽位仅呈现位置),设置的表单级提交 -/// 动作在其视图内容顶部(见 SettingsView)。路由状态仍由 WorkspaceRoute -/// 持有;代理状态卡的 -/// 状态、摘要与配色政策全部经 StatusCardModel 从代理控制工作流的整体 -/// snapshot 派生(issue #47,与状态菜单同口径),本壳只挂 StatusCardView; -/// 服务器分区的活动目标标记同源(snapshot.activeTarget)。 +/// 主窗口标签页外壳:WorkspaceRoute 持有导航位置;各分区持有页级动作。 +/// 仅当前 destination 挂载内容,延续切页清理草稿与取消页面任务的生命周期; +/// 浏览状态与激活反馈仍由窗口壳或组合根持有,跨标签切换存续。 struct MainWindowView: View { /// 纯转发的分区工作流与控制器:壳 body 不读它们(分区视图自行观察), /// 用 let 转发,壳不重复订阅它们的发布。 @@ -42,40 +35,36 @@ struct MainWindowView: View { @StateObject private var homeServerList = HomeServerListState() /// 激活反馈共享状态:首页目标树、侧栏右键与分组详情三个激活入口共用。 @StateObject private var activationFeedback = ActivationFeedbackState() - /// 侧栏选中项中转:macOS 的 List/NavigationSplitView 会在视图更新期间回写 - /// selection(选择再同步,且可能携带缓存的旧值),回写必须落在 SwiftUI 自管 - /// 的 @State 上,再经 onChange 单向驱动 route;直接 publish 到 route 会触发 - /// view-update 期间发布警告,且缓存的旧值会把外部导航弹回。route 的外部 - /// 导航(导入菜单、规则编辑器返回等)经 sidebar 上的 onChange 回填选中项。 - /// 初值须与 WorkspaceRoute 的初始 destination 对齐,否则首帧侧栏无高亮行。 - @State private var sidebarSelection: WorkspaceDestination? = WorkspaceRoute.initialDestination + /// 系统标签选择先落在 SwiftUI 自管状态,再同步到 route,避免在视图 + /// 更新期间发布;外部导航反向同步,route 仍是导航位置的唯一来源。 + @State private var tabSelection: WorkspaceDestination = WorkspaceRoute.initialDestination @State private var selection: NodeID? /// 全局导入面板由窗口壳持有。 @State private var presentedWorkspaceSheet: WorkspaceSheet? var body: some View { - NavigationSplitView { - sidebar - .navigationSplitViewColumnWidth(min: 236, ideal: 250, max: 300) - .toolbar { - ToolbarItem(placement: .automatic) { - importButton - } - } - } detail: { - destinationView - .toolbar { - // macOS 会在详情工具栏项从零变为非零(或反向)时淡入淡出整条 - // 工具栏背景,连带常驻边栏按钮闪烁。保留不可见的零尺寸项, - // 让各页面始终有详情工具栏内容;EmptyView / Spacer 会被系统省略。 - ToolbarItem(id: "workspace-detail-toolbar-anchor", placement: .primaryAction) { - Color.clear - .frame(width: 0, height: 0) - .hidden() - .allowsHitTesting(false) - .accessibilityHidden(true) + TabView(selection: $tabSelection) { + ForEach(WorkspaceDestination.allCases) { destination in + Tab(destination.label, systemImage: destination.systemImage, value: destination) { + if route.destination == destination { + destinationView(destination) } } + } + } + .tabViewStyle(.automatic) + .toolbar { + ToolbarItem(placement: .navigation) { + importButton + } + } + .onChange(of: tabSelection) { _, destination in + route.navigate(to: destination) + } + .onChange(of: route.destination, initial: true) { _, destination in + if tabSelection != destination { + tabSelection = destination + } } .onChange(of: workflow.tree, initial: true) { homeServerList.update(tree: workflow.tree, activeTargetID: control.snapshot.activeTarget?.id) @@ -110,8 +99,8 @@ struct MainWindowView: View { // MARK: - 分区承载 @ViewBuilder - private var destinationView: some View { - switch route.destination { + private func destinationView(_ destination: WorkspaceDestination) -> some View { + switch destination { case .home: WorkspaceHomeView( workflow: workflow, @@ -182,89 +171,6 @@ extension WorkspaceDestination { } } -// MARK: - 侧栏(同文件扩展,保持 private 访问) - -extension MainWindowView { - // MARK: - 侧栏 - - private var sidebar: some View { - List(selection: $sidebarSelection) { - Section { - ForEach(WorkspaceDestination.allCases) { destination in - sidebarRow(destination) - .tag(destination) - } - } - } - .listStyle(.sidebar) - // 点击行 → @State 选中项 → route;nil 回写无对应 destination,忽略。 - .onChange(of: sidebarSelection) { _, destination in - guard let destination else { return } - route.navigate(to: destination) - } - // route 的外部导航 → 回填选中项;等值不写,避免导航回声。 - .onChange(of: route.destination) { _, destination in - if sidebarSelection != destination { - sidebarSelection = destination - } - } - .safeAreaInset(edge: .top, spacing: 4) { identityHeader } - .safeAreaInset(edge: .bottom, spacing: 4) { StatusCardView(control: control) } - } - - private var identityHeader: some View { - HStack(spacing: 10) { - ZStack { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.primary) - Image(systemName: "paperplane.fill") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Color(nsColor: .windowBackgroundColor)) - } - .frame(width: 30, height: 30) - Text("ShadowsocksX-NG2") - .font(.headline) - Spacer(minLength: 0) - } - .padding(.leading, 16) - .padding(.trailing, 12) - .padding(.top, 12) - .padding(.bottom, 4) - } - - private func sidebarRow(_ destination: WorkspaceDestination) -> some View { - HStack(spacing: 8) { - Label(destination.label, systemImage: destination.systemImage) - Spacer(minLength: 0) - if let count = badgeCount(for: destination) { - Text("\(count)") - .font(.caption2.monospacedDigit()) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(Capsule().fill(.quaternary)) - } - } - } - - /// 行尾数量角标:服务器 = 目录树内服务器叶子数;订阅 = 订阅数。 - private func badgeCount(for destination: WorkspaceDestination) -> Int? { - switch destination { - case .servers: - serverLeafCount > 0 ? serverLeafCount : nil - case .subscriptions: - workflow.subscriptions.isEmpty ? nil : workflow.subscriptions.count - default: - nil - } - } - - private var serverLeafCount: Int { - workflow.tree.serverLeafCount - } - -} - // MARK: - 首页 destination 包装壳 /// 首页 destination 的包装视图:承载共享错误弹窗挂载(票 #53/#54),行为 diff --git a/ShadowsocksX-NG2/App/UI/Workspace/StatusCardModel.swift b/ShadowsocksX-NG2/App/UI/Workspace/StatusCardModel.swift index 1c41b255..6419762b 100644 --- a/ShadowsocksX-NG2/App/UI/Workspace/StatusCardModel.swift +++ b/ShadowsocksX-NG2/App/UI/Workspace/StatusCardModel.swift @@ -1,6 +1,6 @@ import Foundation -/// 侧栏底部代理状态卡的呈现模型(issue #60,架构评审候选④):从代理控制 +/// 菜单栏面板代理状态卡的呈现模型(issue #60,架构评审候选④):从代理控制 /// 工作流的整体 snapshot 派生卡片数据——行结构(哪些行、条件 detail、顺序)、 /// 活动目标回退文案与语义色调政策全部在此决定;文本复用 StatusMenuModel 的 /// 同一映射(配色与文本同源)。不持有 SwiftUI/AppKit 类型,纯逻辑可测;视觉 diff --git a/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift b/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift index 18439a40..557b38de 100644 --- a/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift +++ b/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift @@ -1,6 +1,6 @@ import SwiftUI -/// 侧栏底部代理状态卡(issue #60):只做 tone→语义色映射与排版,行结构、 +/// 菜单栏面板代理状态卡(issue #60):只做 tone→语义色映射与排版,行结构、 /// 回退文案与色调政策全部来自 StatusCardModel(与状态菜单同用一份文本映射, /// issue #47 的整体 snapshot 口径)。 struct StatusCardView: View { diff --git a/ShadowsocksX-NG2/App/UI/Workspace/WorkspaceRoute.swift b/ShadowsocksX-NG2/App/UI/Workspace/WorkspaceRoute.swift index 4de94ee8..98c84323 100644 --- a/ShadowsocksX-NG2/App/UI/Workspace/WorkspaceRoute.swift +++ b/ShadowsocksX-NG2/App/UI/Workspace/WorkspaceRoute.swift @@ -25,16 +25,16 @@ final class WorkspaceRoute: ObservableObject { /// scene id 散落(架构测试锚定)。 static let workspaceSceneID = "workspace" - /// 实例初始 destination。MainWindowView 侧栏 @State 选中项的初值须与此 - /// 对齐,否则首帧侧栏无高亮行。 + /// 实例初始 destination。MainWindowView 标签页 @State 选中项的初值须与此 + /// 对齐,否则首帧选中标签不一致。 static let initialDestination: WorkspaceDestination = .home @Published private(set) var destination: WorkspaceDestination = initialDestination /// workspace 内部导航只改变 route destination;开窗不由 route 驱动。 - /// 同值导航必须是无操作:外部导航经 onChange 回填侧栏选中项后会再触发 + /// 同值导航必须是无操作:外部导航经 onChange 回填标签选中项后会再触发 /// 一次 selection onChange(导航回声),由同值短路变成无操作;publish - /// 一旦发生在视图更新期间(List/NavigationSplitView 的选择再同步回写) + /// 一旦发生在视图更新期间(系统控件的选择同步回写) /// 会触发 "Publishing changes from within view updates" 运行时警告。 func navigate(to destination: WorkspaceDestination) { guard destination != self.destination else { return }