mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-28 13:13:47 +00:00
refactor(ui): replace workspace sidebar with tabs
This commit is contained in:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user