mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-01 15:43:45 +00:00
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:
@@ -90,6 +90,7 @@ struct ShadowsocksXNG2App: App {
|
||||
MainWindowView(
|
||||
route: workspaceRoute,
|
||||
workflow: catalogWorkflow,
|
||||
control: proxyControl,
|
||||
proxyController: proxyController,
|
||||
diagnostics: diagnosticsWorkflow,
|
||||
settingsWorkflow: settingsWorkflow,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user