diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index bfc52fab..2a784232 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -90,6 +90,7 @@ struct ShadowsocksXNG2App: App { MainWindowView( route: workspaceRoute, workflow: catalogWorkflow, + control: proxyControl, proxyController: proxyController, diagnostics: diagnosticsWorkflow, settingsWorkflow: settingsWorkflow, diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 2b15d85d..f82a480f 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -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( + _ 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) { + 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) -> 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) { - 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 { + 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 { + 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 } } diff --git a/ShadowsocksX-NG2/App/WorkspaceView.swift b/ShadowsocksX-NG2/App/WorkspaceView.swift index 28d187a6..4526c9c9 100644 --- a/ShadowsocksX-NG2/App/WorkspaceView.swift +++ b/ShadowsocksX-NG2/App/WorkspaceView.swift @@ -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) { - 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 { - 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) -> 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) { + 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 } }