refactor(ui): replace workspace sidebar with tabs

This commit is contained in:
Qiu Yuzhou
2026-10-06 14:44:17 +08:00
parent 4e3a76dba5
commit f7f608b161
6 changed files with 71 additions and 268 deletions
@@ -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 启动即呈现
@@ -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<ProxyMode> {
Binding(
get: { control.snapshot.proxyMode },
set: { mode in Task { await control.setProxyMode(mode) } })
}
/// 规则模式子选项(issue #63):与首页共用同一控制 seam。
private var ruleDefaultActionBinding: Binding<RuleDefaultAction> {
Binding(
get: { control.snapshot.ruleDefaultAction },
set: { action in Task { await control.setRuleDefaultAction(action) } })
}
/// 两个开关绑定持久化意图(issue #60);命令完成由 workflow 整体重发布。
private var agentToggleBinding: Binding<Bool> {
Binding(
get: { control.snapshot.agentIntentEnabled },
set: { enabled in Task { await control.setAgentEnabled(enabled) } })
}
private var systemProxyToggleBinding: Binding<Bool> {
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) {}
}
@@ -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),行为
@@ -1,6 +1,6 @@
import Foundation
/// 侧栏底部代理状态卡的呈现模型(issue #60,架构评审候选④):从代理控制
/// 菜单栏面板代理状态卡的呈现模型(issue #60,架构评审候选④):从代理控制
/// 工作流的整体 snapshot 派生卡片数据——行结构(哪些行、条件 detail、顺序)、
/// 活动目标回退文案与语义色调政策全部在此决定;文本复用 StatusMenuModel 的
/// 同一映射(配色与文本同源)。不持有 SwiftUI/AppKit 类型,纯逻辑可测;视觉
@@ -1,6 +1,6 @@
import SwiftUI
/// 侧栏底部代理状态卡(issue #60):只做 tone→语义色映射与排版,行结构、
/// 菜单栏面板代理状态卡(issue #60):只做 tone→语义色映射与排版,行结构、
/// 回退文案与色调政策全部来自 StatusCardModel(与状态菜单同用一份文本映射,
/// issue #47 的整体 snapshot 口径)。
struct StatusCardView: View {
@@ -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 }