feat(ng2): rebuild main window shell with sidebar navigation (#53)

NavigationSplitView 侧栏:五项导航与 WorkspaceRoute 双向绑定 + 服务器/订阅
行尾数量角标;底部常驻代理状态卡与状态菜单同口径(ProxyControlWorkflow
snapshot 经 StatusMenuModel.summary);分区大标题外壳与动作槽位。
ServersView 内层 NavigationSplitView 换扁平双栏(#55 再重排)。

地图 #52 · 票 #53;task build + test 全绿,hermetic 冒烟通过。
This commit is contained in:
Qiu Yuzhou
2026-09-24 19:29:08 +08:00
parent a70165f5fb
commit 678bea6d3b
3 changed files with 594 additions and 399 deletions
+1
View File
@@ -90,6 +90,7 @@ struct ShadowsocksXNG2App: App {
MainWindowView(
route: workspaceRoute,
workflow: catalogWorkflow,
control: proxyControl,
proxyController: proxyController,
diagnostics: diagnosticsWorkflow,
settingsWorkflow: settingsWorkflow,
+305 -261
View File
@@ -1,59 +1,140 @@
import SwiftUI
/// 服务器 destination(spec #21 D11,issue #32/#34/#35/#41):保留服务器目录树、
/// 详情编辑与导入行为。workspace destination 由外层 MainWindowView 持有;本视图
/// 只持有服务器功能的 sheet、alert、确认弹窗与编辑草稿边界;selection 由
/// workspace feature host 绑定,以便订阅删除仍能清除失效的服务器选择。
struct ServersView: View {
/// 主窗口外壳(地图 #52,票 #53):NavigationSplitView 侧栏承载五项导航与
/// 底部常驻代理状态卡;详情区按 route destination 承载既有五个分区视图,
/// 顶部是系统设置风格的分区大标题(动作槽位留给各分区票填充)。路由状态
/// 仍由 WorkspaceRoute 持有;代理状态卡的开关、模式与摘要只来自代理控制
/// 工作流的整体 snapshot(issue #47),与状态菜单同一口径。
struct MainWindowView: View {
@ObservedObject var route: WorkspaceRoute
@ObservedObject var workflow: CatalogWorkflow
/// 运行时事实来源:活动目标标记;激活命令经 `workflow.activate`。
@ObservedObject var control: ProxyControlWorkflow
@ObservedObject var proxyController: ProxyRuntimeController
@Binding var selection: NodeID?
@ObservedObject var diagnostics: DiagnosticsWorkflow
@ObservedObject var settingsWorkflow: SettingsWorkflow
@ObservedObject var loginController: LaunchAtLoginController
let clipboard: any TextClipboard
let diagnosticReportExporter: any DiagnosticReportExporter
/// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。
@StateObject private var errors = ErrorAlertPresenter()
// 纯窗口状态(issue #41):selection/sheet/alert 不进目录 module。
@State private var renameTarget: NodeID?
@State private var renameText = ""
@State private var newGroupParent: NodeID?
@State private var newGroupName = ""
@State private var deleteTarget: NodeID?
@State private var moveTarget: NodeID?
@State private var showImportURLSheet = false
@State private var showQRImportSheet = false
@State private var showLegacyImportSheet = false
@State private var rootDropHovering = false
@State private var selection: NodeID?
var body: some View {
NavigationSplitView {
serverSidebar
sidebar
.navigationSplitViewColumnWidth(min: 200, ideal: 236, max: 300)
} detail: {
serverDetailPane
VStack(spacing: 0) {
destinationHeader(route.destination.label) {
headerActions
}
destinationView
}
}
.frame(minWidth: 720, minHeight: 420)
.alert(
"重命名分组",
isPresented: Binding(
get: { renameTarget != nil },
set: { if !$0 { renameTarget = nil } })
) {
TextField("名称", text: $renameText)
Button("确定") { commitRename() }
Button("取消", role: .cancel) { renameTarget = nil }
.frame(minWidth: 920, minHeight: 580)
}
// MARK: - 分区标题与动作槽位
@ViewBuilder
private func destinationHeader<ActionContent: View>(
_ title: String,
@ViewBuilder actions: () -> ActionContent
) -> some View {
VStack(spacing: 0) {
HStack(alignment: .center) {
Text(title)
.font(.system(size: 28, weight: .bold))
.lineLimit(1)
Spacer(minLength: 16)
actions()
}
.padding(.leading, 28)
.padding(.trailing, 32)
.padding(.top, 18)
.padding(.bottom, 14)
Divider()
}
.alert(
"新建分组",
isPresented: Binding(
get: { newGroupParent != nil },
set: { if !$0 { newGroupParent = nil } })
) {
TextField("名称", text: $newGroupName)
Button("创建") { commitNewGroup() }
Button("取消", role: .cancel) { newGroupParent = nil }
}
/// 分区头动作槽位:各分区票落地时把页级动作(添加、保存、导出等)迁入。
@ViewBuilder
private var headerActions: some View {
switch route.destination {
case .home, .servers, .subscriptions, .settings, .diagnostics:
EmptyView()
}
}
// MARK: - 分区承载
@ViewBuilder
private var destinationView: some View {
switch route.destination {
case .home:
WorkspaceHomeView(workflow: workflow)
case .servers:
ServersView(
workflow: workflow,
proxyController: proxyController,
selection: $selection,
clipboard: clipboard)
case .subscriptions:
WorkspaceSubscriptionsView(
workflow: workflow,
onNodesRemoved: clearSelectionIfInvalidated)
case .settings:
SettingsView(workflow: settingsWorkflow, loginController: loginController)
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .diagnostics:
WorkspaceDiagnosticsView(
diagnostics: diagnostics,
clipboard: clipboard,
reportExporter: diagnosticReportExporter)
}
}
private func clearSelectionIfInvalidated(_ removed: Set<NodeID>) {
if let selection, removed.contains(selection) {
self.selection = nil
}
}
}
// MARK: - 分区包装(票 #53)
private struct WorkspaceHomeView: View {
@ObservedObject var workflow: CatalogWorkflow
@State private var showLegacyImportSheet = false
@State private var didOfferLegacyImport = false
var body: some View {
ContentUnavailableView(
"首页", systemImage: "house",
description: Text("从侧栏选择服务器、订阅、设置或诊断。")
)
.sheet(isPresented: $showLegacyImportSheet) {
LegacyImportSheet(workflow: workflow)
}
.onAppear {
guard !didOfferLegacyImport, workflow.legacyImportState.shouldOffer else { return }
didOfferLegacyImport = true
showLegacyImportSheet = true
}
}
}
private struct WorkspaceSubscriptionsView: View {
@ObservedObject var workflow: CatalogWorkflow
let onNodesRemoved: (Set<NodeID>) -> Void
@StateObject private var errors = ErrorAlertPresenter()
var body: some View {
SubscriptionsView(
workflow: workflow,
errors: errors,
onNodesRemoved: onNodesRemoved
)
.alert(
"操作失败",
isPresented: Binding(
@@ -64,248 +145,211 @@ struct ServersView: View {
} message: {
Text(errors.message ?? "")
}
.confirmationDialog(
deleteTitle,
isPresented: Binding(
get: { deleteTarget != nil },
set: { if !$0 { deleteTarget = nil } }),
titleVisibility: .visible
) {
Button("删除", role: .destructive) { commitDelete() }
Button("取消", role: .cancel) { deleteTarget = nil }
} message: {
Text(deleteMessage)
}
}
private struct WorkspaceDiagnosticsView: View {
@ObservedObject var diagnostics: DiagnosticsWorkflow
let clipboard: any TextClipboard
let reportExporter: any DiagnosticReportExporter
@StateObject private var errors = ErrorAlertPresenter()
var body: some View {
HStack(spacing: 0) {
DiagnosticsSummarySidebar(workflow: diagnostics)
.frame(width: 220)
Divider()
DiagnosticsView(
diagnostics: diagnostics,
errors: errors,
clipboard: clipboard,
reportExporter: reportExporter
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.alert(
"操作失败",
isPresented: Binding(
get: { errors.isPresented },
set: { if !$0 { errors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(errors.message ?? "")
}
}
.sheet(isPresented: $showImportURLSheet) {
ImportURLSheet(
workflow: workflow, errors: errors, clipboard: clipboard, selection: $selection)
}
.sheet(isPresented: $showQRImportSheet) {
QRImportSheet(workflow: workflow, errors: errors, selection: $selection)
}
.sheet(isPresented: $showLegacyImportSheet) {
LegacyImportSheet(workflow: workflow)
}
.sheet(
item: Binding(
get: { moveTarget.map(MoveContext.init) },
set: { moveTarget = $0?.nodeID })
) { context in
MoveNodeSheet(workflow: workflow, errors: errors, nodeID: context.nodeID)
}
}
extension WorkspaceDestination {
var label: String {
switch self {
case .home: "首页"
case .servers: "服务器"
case .subscriptions: "订阅"
case .settings: "设置"
case .diagnostics: "诊断"
}
}
private var serverSidebar: some View {
List(selection: $selection) {
OutlineGroup(workflow.tree.roots, children: \.children) { node in
SidebarRow(
node: node,
workflow: workflow,
activeTargetID: proxyController.activeTargetID,
errors: errors,
onRename: { id in
renameTarget = id
renameText = workflow.displayName(for: id)
},
onNewGroup: { parent in
newGroupParent = parent
newGroupName = ""
},
onMove: { moveTarget = $0 },
onDelete: { deleteTarget = $0 }
)
.onDrag {
guard let payload = workflow.dragPayload(for: node.id) else {
return NSItemProvider()
}
return NSItemProvider(object: payload as NSString)
}
.dropDestination(for: String.self) { payload, _ in
handleDrop(payload, onto: node.id)
} isTargeted: { _ in
var systemImage: String {
switch self {
case .home: "house"
case .servers: "server.rack"
case .subscriptions: "arrow.triangle.2.circlepath"
case .settings: "gearshape"
case .diagnostics: "chart.bar"
}
}
}
// MARK: - 侧栏与底部代理状态卡(同文件扩展,保持 private 访问)
extension MainWindowView {
// MARK: - 侧栏
private var sidebar: some View {
List(selection: navigationBinding) {
Section {
ForEach(WorkspaceDestination.allCases) { destination in
sidebarRow(destination)
.tag(destination)
}
}
}
.listStyle(.sidebar)
.overlay(alignment: .center) {
if workflow.tree.isEmpty {
ContentUnavailableView(
"暂无服务器", systemImage: "server.rack",
description: Text("用工具栏「添加」导入 ss:// 链接或新建分组")
)
.allowsHitTesting(false)
.safeAreaInset(edge: .top, spacing: 0) { identityHeader }
.safeAreaInset(edge: .bottom, spacing: 0) { statusCard }
}
private var identityHeader: some View {
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.primary)
Image(systemName: "network")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Color(nsColor: .windowBackgroundColor))
}
.frame(width: 30, height: 30)
Text("ShadowsocksX-NG")
.font(.headline)
Spacer(minLength: 0)
}
.dropDestination(for: String.self) { payload, _ in
handleDrop(payload, onto: nil)
} isTargeted: { hovering in
rootDropHovering = hovering
}
.toolbar { toolbarContent }
.padding(.leading, 16)
.padding(.trailing, 12)
.padding(.top, 12)
.padding(.bottom, 4)
}
/// 删除结果 → selection invalidation(story 18):仅清除失效选择,不自动
/// 改选相邻节点。
private func clearSelectionIfInvalidated(_ removed: Set<NodeID>) {
if let selection, removed.contains(selection) {
self.selection = nil
}
}
/// 落点语义:拖到分组行 = 移入该组(仅手动组接受),拖到列表空白/根 = 移到根。
/// 资格事实由 seam 提供;跨来源/成环仍由领域拒绝(不变量防线)。
private func handleDrop(_ payload: [String], onto target: NodeID?) -> Bool {
guard let raw = payload.first else { return false }
let dragged = NodeID(rawValue: raw)
guard workflow.canMove(dragged, to: target) else { return false }
Task {
do {
try await workflow.move(dragged, to: target)
} catch {
errors.present(error)
}
}
return true
}
// MARK: - 详情区
@ViewBuilder
private var serverDetailPane: some View {
if let id = selection, let node = workflow.tree.node(withID: id) {
if node.isGroup {
GroupDetailView(
workflow: workflow, groupID: id,
errors: errors)
} else {
ServerDetailView(
workflow: workflow, serverID: id,
errors: errors, clipboard: clipboard)
}
} else {
ContentUnavailableView(
"未选择节点", systemImage: "sidebar.left",
description: Text("在左侧选择服务器或分组查看与编辑详情"))
}
}
// MARK: - 工具栏(添加三入口 + 新建分组)
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .primaryAction) {
Menu {
Button("从剪贴板导入 ss://") { importFromClipboard() }
Button("通过 URL 导入…") { showImportURLSheet = true }
Button("从二维码图片导入…") { showQRImportSheet = true }
if workflow.legacyImportState.snapshotFound {
Divider()
Button(
workflow.legacyImportState.completed ? "再次导入 Legacy 配置…" : "导入 Legacy 配置…"
) {
showLegacyImportSheet = true
}
}
Divider()
Button("新建分组…") {
newGroupParent = workflow.importTargetParent(for: selection)
newGroupName = ""
}
} label: {
Label("添加", systemImage: "plus")
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 importFromClipboard() {
let text = clipboard.read() ?? ""
performImport(text)
private var navigationBinding: Binding<WorkspaceDestination?> {
Binding(
get: { route.destination },
set: { destination in
guard let destination else { return }
route.navigate(to: destination)
})
}
private func performImport(_ text: String) {
Task {
do {
let parent = workflow.importTargetParent(for: selection)
let outcome = try await workflow.createServers(fromURIs: text, into: parent)
if outcome.addedCount == 0 && outcome.failures.isEmpty {
errors.present(text: "剪贴板没有可导入的文本")
} else if let message = ImportOutcomePresentation.failureMessage(outcome) {
errors.present(text: message)
}
} catch {
errors.present(error)
/// 行尾数量角标:服务器 = 目录树内服务器叶子数;订阅 = 订阅数。
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 {
func leaves(_ nodes: [CatalogTreeNode]) -> Int {
nodes.reduce(0) { $0 + ($1.isGroup ? leaves($1.childNodes) : 1) }
}
return leaves(workflow.tree.roots)
}
// MARK: - 底部代理状态卡
private var statusCard: some View {
let summary = StatusMenuModel.summary(from: control.snapshot)
return VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 8) {
Toggle("代理开关", isOn: proxyToggleBinding)
.toggleStyle(.switch)
.controlSize(.mini)
.labelsHidden()
Text(summary.status)
.font(.footnote.weight(.semibold))
.foregroundStyle(statusColor(summary))
.lineLimit(1)
}
Text(summary.targetPath ?? "未激活")
.font(.footnote.weight(.medium))
.lineLimit(1)
.truncationMode(.middle)
.help(
summary.targetPath.map { "活动目标:\($0)" }
?? "未设置活动目标;在首页或服务器目录中激活")
Text("模式:\(summary.modeLabel)")
.font(.caption)
.foregroundStyle(.secondary)
if let detail = summary.detail {
Text(detail)
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
.help(detail)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(.quaternary)
)
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 12)
}
// MARK: - 告警动作
private var proxyToggleBinding: Binding<Bool> {
Binding(
get: { control.snapshot.runtime.isOn },
set: { enabled in
Task { await control.setProxyEnabled(enabled) }
})
}
private func commitRename() {
guard let id = renameTarget else { return }
renameTarget = nil
Task {
do {
try await workflow.renameGroup(id, to: renameText)
} catch {
errors.present(error)
}
private func statusColor(_ summary: StatusMenuModel.Summary) -> Color {
switch control.snapshot.runtime.status {
case .running:
.green
case .starting:
.secondary
case .off:
.secondary
case .firewallBlocked, .requiresApproval:
.orange
case .launchFailed, .activationFailed, .serviceFailed, .systemProxyFailed:
.red
}
}
private func commitNewGroup() {
guard let parent = newGroupParent else { return }
newGroupParent = nil
Task {
do {
_ = try await workflow.createGroup(named: newGroupName, into: parent)
} catch {
errors.present(error)
}
}
}
/// 删除确认文案:档位与规模事实来自 seam;句子由 UI 拼(Q3-A)。
private var deleteTitle: String {
guard let id = deleteTarget else { return "" }
let name = workflow.displayName(for: id)
switch workflow.deleteFacts(for: id) {
case .subtree:
return "删除分组「\(name)」及其整棵子树?"
case .leaf, .emptyGroup, nil:
return "删除「\(name)」?"
}
}
private var deleteMessage: String {
guard let id = deleteTarget else { return "" }
switch workflow.deleteFacts(for: id) {
case .subtree(let count, let includesCredentials):
if includesCredentials {
return "将递归删除 \(count) 个节点(含其中的服务器与凭据),此操作不可撤销。"
}
return "将递归删除 \(count) 个节点,此操作不可撤销。"
case .leaf, .emptyGroup, nil:
return "此操作不可撤销。"
}
}
private func commitDelete() {
guard let id = deleteTarget else { return }
deleteTarget = nil
Task {
do {
let outcome = try await workflow.remove(id)
clearSelectionIfInvalidated(outcome.removedNodeIDs)
} catch {
errors.present(error)
}
}
}
}
/// moveTarget 的 sheet(item:) 适配壳。
private struct MoveContext: Identifiable {
let nodeID: NodeID
var id: NodeID { nodeID }
}
+288 -138
View File
@@ -1,115 +1,64 @@
import SwiftUI
/// 单主 workspace host。它只把 route destination 映射到已有 feature view;目录、
/// 订阅、设置与诊断的行为仍由各自既有 module/view 持有。
struct MainWindowView: View {
@ObservedObject var route: WorkspaceRoute
/// 服务器 destination(spec #21 D11,issue #32/#34/#35/#41):保留服务器目录树、
/// 详情编辑与导入行为。workspace destination 由外层主窗口壳持有(新壳在
/// MainWindowView.swift,票 #53);本视图只持有服务器功能的 sheet、alert、
/// 确认弹窗与编辑草稿边界;selection 由外层绑定,以便订阅删除仍能清除失效
/// 的服务器选择。
struct ServersView: View {
@ObservedObject var workflow: CatalogWorkflow
/// 运行时事实来源:活动目标标记;激活命令经 `workflow.activate`。
@ObservedObject var proxyController: ProxyRuntimeController
@ObservedObject var diagnostics: DiagnosticsWorkflow
@ObservedObject var settingsWorkflow: SettingsWorkflow
@ObservedObject var loginController: LaunchAtLoginController
@Binding var selection: NodeID?
let clipboard: any TextClipboard
let diagnosticReportExporter: any DiagnosticReportExporter
@State private var selection: NodeID?
var body: some View {
VStack(spacing: 0) {
WorkspaceDestinationPicker(route: route)
destinationView
}
.frame(minWidth: 720, minHeight: 420)
}
@ViewBuilder
private var destinationView: some View {
switch route.destination {
case .home:
WorkspaceHomeView(workflow: workflow)
case .servers:
ServersView(
workflow: workflow,
proxyController: proxyController,
selection: $selection,
clipboard: clipboard)
case .subscriptions:
WorkspaceSubscriptionsView(
workflow: workflow,
onNodesRemoved: clearSelectionIfInvalidated)
case .settings:
SettingsView(workflow: settingsWorkflow, loginController: loginController)
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .diagnostics:
WorkspaceDiagnosticsView(
diagnostics: diagnostics,
clipboard: clipboard,
reportExporter: diagnosticReportExporter)
}
}
private func clearSelectionIfInvalidated(_ removed: Set<NodeID>) {
if let selection, removed.contains(selection) {
self.selection = nil
}
}
}
private struct WorkspaceDestinationPicker: View {
@ObservedObject var route: WorkspaceRoute
var body: some View {
Picker("工作区", selection: destinationBinding) {
ForEach(WorkspaceDestination.allCases) { destination in
Text(destination.label).tag(destination)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
private var destinationBinding: Binding<WorkspaceDestination> {
Binding(
get: { route.destination },
set: { route.navigate(to: $0) })
}
}
private struct WorkspaceHomeView: View {
@ObservedObject var workflow: CatalogWorkflow
@State private var showLegacyImportSheet = false
@State private var didOfferLegacyImport = false
var body: some View {
ContentUnavailableView(
"首页", systemImage: "house",
description: Text("从工作区导航选择服务器、订阅、设置或诊断。")
)
.sheet(isPresented: $showLegacyImportSheet) {
LegacyImportSheet(workflow: workflow)
}
.onAppear {
guard !didOfferLegacyImport, workflow.legacyImportState.shouldOffer else { return }
didOfferLegacyImport = true
showLegacyImportSheet = true
}
}
}
private struct WorkspaceSubscriptionsView: View {
@ObservedObject var workflow: CatalogWorkflow
let onNodesRemoved: (Set<NodeID>) -> Void
/// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。
@StateObject private var errors = ErrorAlertPresenter()
// 纯窗口状态(issue #41):selection/sheet/alert 不进目录 module。
@State private var renameTarget: NodeID?
@State private var renameText = ""
@State private var newGroupParent: NodeID?
@State private var newGroupName = ""
@State private var deleteTarget: NodeID?
@State private var moveTarget: NodeID?
@State private var showImportURLSheet = false
@State private var showQRImportSheet = false
@State private var showLegacyImportSheet = false
@State private var rootDropHovering = false
var body: some View {
SubscriptionsView(
workflow: workflow,
errors: errors,
onNodesRemoved: onNodesRemoved
)
// 主窗口外壳(票 #53)已提供分栏框架;本分区用扁平双栏承载目录与详情
// (票 #55 再按原型重排)。
HStack(alignment: .top, spacing: 0) {
serverSidebar
.frame(minWidth: 240, idealWidth: 280, maxWidth: 340)
Divider()
serverDetailPane
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minHeight: 420)
.alert(
"重命名分组",
isPresented: Binding(
get: { renameTarget != nil },
set: { if !$0 { renameTarget = nil } })
) {
TextField("名称", text: $renameText)
Button("确定") { commitRename() }
Button("取消", role: .cancel) { renameTarget = nil }
}
.alert(
"新建分组",
isPresented: Binding(
get: { newGroupParent != nil },
set: { if !$0 { newGroupParent = nil } })
) {
TextField("名称", text: $newGroupName)
Button("创建") { commitNewGroup() }
Button("取消", role: .cancel) { newGroupParent = nil }
}
.alert(
"操作失败",
isPresented: Binding(
@@ -120,47 +69,248 @@ private struct WorkspaceSubscriptionsView: View {
} message: {
Text(errors.message ?? "")
}
.confirmationDialog(
deleteTitle,
isPresented: Binding(
get: { deleteTarget != nil },
set: { if !$0 { deleteTarget = nil } }),
titleVisibility: .visible
) {
Button("删除", role: .destructive) { commitDelete() }
Button("取消", role: .cancel) { deleteTarget = nil }
} message: {
Text(deleteMessage)
}
.sheet(isPresented: $showImportURLSheet) {
ImportURLSheet(
workflow: workflow, errors: errors, clipboard: clipboard, selection: $selection)
}
.sheet(isPresented: $showQRImportSheet) {
QRImportSheet(workflow: workflow, errors: errors, selection: $selection)
}
.sheet(isPresented: $showLegacyImportSheet) {
LegacyImportSheet(workflow: workflow)
}
.sheet(
item: Binding(
get: { moveTarget.map(MoveContext.init) },
set: { moveTarget = $0?.nodeID })
) { context in
MoveNodeSheet(workflow: workflow, errors: errors, nodeID: context.nodeID)
}
}
}
private struct WorkspaceDiagnosticsView: View {
@ObservedObject var diagnostics: DiagnosticsWorkflow
let clipboard: any TextClipboard
let reportExporter: any DiagnosticReportExporter
@StateObject private var errors = ErrorAlertPresenter()
private var serverSidebar: some View {
List(selection: $selection) {
OutlineGroup(workflow.tree.roots, children: \.children) { node in
SidebarRow(
node: node,
workflow: workflow,
activeTargetID: proxyController.activeTargetID,
errors: errors,
onRename: { id in
renameTarget = id
renameText = workflow.displayName(for: id)
},
onNewGroup: { parent in
newGroupParent = parent
newGroupName = ""
},
onMove: { moveTarget = $0 },
onDelete: { deleteTarget = $0 }
)
.onDrag {
guard let payload = workflow.dragPayload(for: node.id) else {
return NSItemProvider()
}
return NSItemProvider(object: payload as NSString)
}
.dropDestination(for: String.self) { payload, _ in
handleDrop(payload, onto: node.id)
} isTargeted: { _ in
}
}
}
.listStyle(.sidebar)
.overlay(alignment: .center) {
if workflow.tree.isEmpty {
ContentUnavailableView(
"暂无服务器", systemImage: "server.rack",
description: Text("用工具栏「添加」导入 ss:// 链接或新建分组")
)
.allowsHitTesting(false)
}
}
.dropDestination(for: String.self) { payload, _ in
handleDrop(payload, onto: nil)
} isTargeted: { hovering in
rootDropHovering = hovering
}
.toolbar { toolbarContent }
}
var body: some View {
NavigationSplitView {
DiagnosticsSummarySidebar(workflow: diagnostics)
} detail: {
DiagnosticsView(
diagnostics: diagnostics,
errors: errors,
clipboard: clipboard,
reportExporter: reportExporter
)
.alert(
"操作失败",
isPresented: Binding(
get: { errors.isPresented },
set: { if !$0 { errors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(errors.message ?? "")
/// 删除结果 → selection invalidation(story 18):仅清除失效选择,不自动
/// 改选相邻节点。
private func clearSelectionIfInvalidated(_ removed: Set<NodeID>) {
if let selection, removed.contains(selection) {
self.selection = nil
}
}
/// 落点语义:拖到分组行 = 移入该组(仅手动组接受),拖到列表空白/根 = 移到根。
/// 资格事实由 seam 提供;跨来源/成环仍由领域拒绝(不变量防线)。
private func handleDrop(_ payload: [String], onto target: NodeID?) -> Bool {
guard let raw = payload.first else { return false }
let dragged = NodeID(rawValue: raw)
guard workflow.canMove(dragged, to: target) else { return false }
Task {
do {
try await workflow.move(dragged, to: target)
} catch {
errors.present(error)
}
}
return true
}
// MARK: - 详情区
@ViewBuilder
private var serverDetailPane: some View {
if let id = selection, let node = workflow.tree.node(withID: id) {
if node.isGroup {
GroupDetailView(
workflow: workflow, groupID: id,
errors: errors)
} else {
ServerDetailView(
workflow: workflow, serverID: id,
errors: errors, clipboard: clipboard)
}
} else {
ContentUnavailableView(
"未选择节点", systemImage: "sidebar.left",
description: Text("在左侧选择服务器或分组查看与编辑详情"))
}
}
// MARK: - 工具栏(添加三入口 + 新建分组)
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .primaryAction) {
Menu {
Button("从剪贴板导入 ss://") { importFromClipboard() }
Button("通过 URL 导入…") { showImportURLSheet = true }
Button("从二维码图片导入…") { showQRImportSheet = true }
if workflow.legacyImportState.snapshotFound {
Divider()
Button(
workflow.legacyImportState.completed ? "再次导入 Legacy 配置…" : "导入 Legacy 配置…"
) {
showLegacyImportSheet = true
}
}
Divider()
Button("新建分组…") {
newGroupParent = workflow.importTargetParent(for: selection)
newGroupName = ""
}
} label: {
Label("添加", systemImage: "plus")
}
}
}
/// 剪贴板入口:整段剪贴板文本按行解析导入当前落点。
private func importFromClipboard() {
let text = clipboard.read() ?? ""
performImport(text)
}
private func performImport(_ text: String) {
Task {
do {
let parent = workflow.importTargetParent(for: selection)
let outcome = try await workflow.createServers(fromURIs: text, into: parent)
if outcome.addedCount == 0 && outcome.failures.isEmpty {
errors.present(text: "剪贴板没有可导入的文本")
} else if let message = ImportOutcomePresentation.failureMessage(outcome) {
errors.present(text: message)
}
} catch {
errors.present(error)
}
}
}
// MARK: - 告警动作
private func commitRename() {
guard let id = renameTarget else { return }
renameTarget = nil
Task {
do {
try await workflow.renameGroup(id, to: renameText)
} catch {
errors.present(error)
}
}
}
private func commitNewGroup() {
guard let parent = newGroupParent else { return }
newGroupParent = nil
Task {
do {
_ = try await workflow.createGroup(named: newGroupName, into: parent)
} catch {
errors.present(error)
}
}
}
/// 删除确认文案:档位与规模事实来自 seam;句子由 UI 拼(Q3-A)。
private var deleteTitle: String {
guard let id = deleteTarget else { return "" }
let name = workflow.displayName(for: id)
switch workflow.deleteFacts(for: id) {
case .subtree:
return "删除分组「\(name)」及其整棵子树?"
case .leaf, .emptyGroup, nil:
return "删除「\(name)」?"
}
}
private var deleteMessage: String {
guard let id = deleteTarget else { return "" }
switch workflow.deleteFacts(for: id) {
case .subtree(let count, let includesCredentials):
if includesCredentials {
return "将递归删除 \(count) 个节点(含其中的服务器与凭据),此操作不可撤销。"
}
return "将递归删除 \(count) 个节点,此操作不可撤销。"
case .leaf, .emptyGroup, nil:
return "此操作不可撤销。"
}
}
private func commitDelete() {
guard let id = deleteTarget else { return }
deleteTarget = nil
Task {
do {
let outcome = try await workflow.remove(id)
clearSelectionIfInvalidated(outcome.removedNodeIDs)
} catch {
errors.present(error)
}
}
}
}
extension WorkspaceDestination {
fileprivate var label: String {
switch self {
case .home: "首页"
case .servers: "服务器"
case .subscriptions: "订阅"
case .settings: "设置"
case .diagnostics: "诊断"
}
}
/// moveTarget 的 sheet(item:) 适配壳。
private struct MoveContext: Identifiable {
let nodeID: NodeID
var id: NodeID { nodeID }
}