diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Policy.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Policy.swift index d9cf59a7..777039df 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Policy.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Policy.swift @@ -1,6 +1,6 @@ import Foundation -/// 目录政策查询与激活命令(issue #41):删除确认档位、移动目的地、拖放资格 +/// 目录政策查询与激活命令(issue #41):删除确认档位、拖放资格 /// 与激活资格/命令的唯一 UI-facing 出口。视图不得重推 ownership、删除确认或 /// ADR-0001 激活预检;领域拒绝仍是不变量防线。 extension CatalogWorkflow { @@ -30,29 +30,13 @@ extension CatalogWorkflow { return node.childNodes.contains { subtreeHasServers($0) } } - // MARK: - 移动目的地与拖放资格 - - /// 「移动到」目的地:目录根 + 除自身子树外的全部手动组(depth 供展示缩进)。 - func moveDestinations(for id: NodeID) -> [MoveDestination] { - var result = [MoveDestination(id: nil, name: "目录根", depth: 0)] - let excluded = tree.node(withID: id)?.subtreeIDs ?? [id] - func walk(_ nodes: [CatalogTreeNode], depth: Int) { - for node in nodes { - guard node.isGroup, node.isManual else { continue } - guard !excluded.contains(node.id) else { continue } - result.append(MoveDestination(id: node.id, name: node.name, depth: depth)) - walk(node.childNodes, depth: depth + 1) - } - } - walk(tree.roots, depth: 0) - return result - } + // MARK: - 拖放资格 /// 移动/落点资格:存在性、非自身、被拖节点可拖、目标为根或不在被拖子树内 - /// 的手动组。跨来源与成环仍由领域拒绝(不变量防线,不在此复制)。 + /// 的手动组;同父落点不提供重排。跨来源与成环仍由领域拒绝。 func canMove(_ id: NodeID, to parent: NodeID?) -> Bool { guard let node = tree.node(withID: id) else { return false } - guard id != parent else { return false } + guard id != parent, node.parentID != parent else { return false } guard node.isManual else { return false } guard let parent else { return true } guard let parentNode = tree.node(withID: parent), parentNode.isGroup, parentNode.isManual else { diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift index baaae2c1..1ea3bf16 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift @@ -15,6 +15,8 @@ struct CatalogTreeNode: Identifiable, Equatable { let source: NodeSource /// 父节点身份;目录根层节点为 `nil`。 let parentID: NodeID? + let createdAt: Date? + let updatedAt: Date? /// 服务器叶子的已知阻塞原因(typed,无成句文案);分组为空。 let invalidReasons: [LeafInvalidationReason] /// 直接子节点数(分组)。 @@ -159,6 +161,8 @@ extension CatalogTreeSnapshot { isGroup: true, source: entry.source, parentID: parentID, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, invalidReasons: [], childCount: children.count, subtreeNodeCount: children.reduce(0) { $0 + 1 + $1.subtreeNodeCount }, @@ -173,6 +177,8 @@ extension CatalogTreeSnapshot { isGroup: false, source: entry.source, parentID: parentID, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, invalidReasons: validation.issues, childCount: 0, subtreeNodeCount: 0, @@ -200,15 +206,6 @@ enum DeleteConfirmKind: Equatable, Sendable { case subtree(count: Int, includesCredentials: Bool) } -/// 「移动到」目的地(目录根 + 除自身子树外的全部手动组)。 -struct MoveDestination: Identifiable, Equatable, Sendable { - /// `nil` = 目录根。 - let id: NodeID? - let name: String - /// 展示缩进深度(根为 0)。 - let depth: Int -} - /// 激活资格事实(点前门禁;与 `ActivationCommandOutcome` 的点后结果区分)。 struct ActivationEligibility: Equatable, Sendable { enum Ineligibility: Equatable, Sendable { diff --git a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings index abbe2b3a..df261f48 100644 --- a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings @@ -1122,6 +1122,72 @@ } } } + }, + "本地": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Local" + } + } + } + }, + "订阅": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Subscriptions" + } + } + } + }, + "创建时间": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date Created" + } + } + } + }, + "修改时间": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date Modified" + } + } + } + }, + "无": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + } + } + }, + "排序": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort" + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/UI/Servers/CatalogExpansionState.swift b/ShadowsocksX-NG2/App/UI/Servers/CatalogExpansionState.swift index 419d2e5b..d2c393cf 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/CatalogExpansionState.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/CatalogExpansionState.swift @@ -20,6 +20,19 @@ final class CatalogExpansionState: ObservableObject { } } + /// 收起包含选中节点的组时,选择该组,避免详情指向隐藏的后代。 + func setExpanded(_ expanded: Bool, for group: CatalogTreeNode, selection: NodeID?) -> NodeID? { + if expanded { + collapsedGroupIDs.remove(group.id) + } else { + collapsedGroupIDs.insert(group.id) + if let selection, selection != group.id, group.find(selection) != nil { + return group.id + } + } + return selection + } + /// 创建成功后仅展开祖先,保留其他分组的折叠状态。 func reveal(_ id: NodeID, in tree: CatalogTreeSnapshot) { var parent = tree.node(withID: id)?.parentID diff --git a/ShadowsocksX-NG2/App/UI/Servers/MoveNodeSheet.swift b/ShadowsocksX-NG2/App/UI/Servers/MoveNodeSheet.swift deleted file mode 100644 index 1ffb0deb..00000000 --- a/ShadowsocksX-NG2/App/UI/Servers/MoveNodeSheet.swift +++ /dev/null @@ -1,62 +0,0 @@ -import SwiftUI - -/// 「移动到」表单(issue #41):目的地由 seam 的 `moveDestinations` 提供—— -/// 目录根与除自身子树外的全部手动组;跨来源与成环由领域层最终拒绝。 -struct MoveNodeSheet: View { - let workflow: CatalogWorkflow - let errors: ErrorAlertPresenter - let nodeID: NodeID - @Environment(\.dismiss) private var dismiss - - @State private var destination: NodeID? - - private struct Destination: Identifiable { - let id: NodeID? - let title: String - } - - private var destinations: [Destination] { - workflow.moveDestinations(for: nodeID).map { item in - let indent = String(repeating: " ", count: item.depth) - return Destination(id: item.id, title: indent + item.name) - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("把「\(workflow.displayName(for: nodeID))」移动到:") - .font(.callout) - Picker( - "目的地", - selection: Binding( - get: { destination ?? destinations.first?.id }, - set: { destination = $0 } - ) - ) { - ForEach(destinations) { target in - Text(target.title).tag(target.id as NodeID?) - } - } - .pickerStyle(.radioGroup) - HStack { - Spacer() - Button("取消", role: .cancel) { dismiss() } - Button("移动") { move() } - .keyboardShortcut(.defaultAction) - } - } - .padding(20) - .frame(minWidth: 380) - } - - private func move() { - Task { - do { - try await workflow.move(nodeID, to: destination) - dismiss() - } catch { - errors.present(error) - } - } - } -} diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServerTableDrop.swift b/ShadowsocksX-NG2/App/UI/Servers/ServerTableDrop.swift new file mode 100644 index 00000000..a723a64f --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Servers/ServerTableDrop.swift @@ -0,0 +1,139 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +extension UTType { + static let serverCatalogNode = UTType(exportedAs: "com.qiuyuzhou.ShadowsocksX-NG2.catalog-node") +} + +/// 行和列表空白使用同一个落点判定,避免拒绝的行退回根落点。 +@MainActor +final class ServerTableDropState { + weak var reader: ServerTableDropReaderView? + private(set) var draggedID: NodeID? + + func provider(for id: NodeID, payload: String) -> NSItemProvider { + draggedID = id + let provider = NSItemProvider() + let data = Data(payload.utf8) + provider.registerDataRepresentation( + forTypeIdentifier: UTType.serverCatalogNode.identifier, visibility: .ownProcess + ) { completion in + completion(data, nil) + return nil + } + return provider + } +} + +/// SwiftUI TableRowContent 的 dropDestination 不能返回拒绝结果。 +/// 此 Adapter 仅借用 NSTableView 的行命中检测,不替换 Table 或其 delegate。 +struct ServerTableDropReader: NSViewRepresentable { + let state: ServerTableDropState + + func makeNSView(context: Context) -> ServerTableDropReaderView { + let view = ServerTableDropReaderView() + state.reader = view + return view + } + + func updateNSView(_ nsView: ServerTableDropReaderView, context: Context) { + state.reader = nsView + } +} + +final class ServerTableDropReaderView: NSView { + override var isFlipped: Bool { true } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + /// nil 表示尚未挂载、表头或行数不同步;-1 表示列表空白。 + func row(at point: CGPoint, expectedCount: Int) -> Int? { + guard bounds.contains(point), let table = tableView(), table.numberOfRows == expectedCount + else { + return nil + } + if let header = table.headerView, + header.bounds.contains(header.convert(point, from: self)) + { + return nil + } + return table.row(at: table.convert(point, from: self)) + } + + private func tableView() -> NSTableView? { + func find(in view: NSView) -> NSTableView? { + if view === self { return nil } + if let table = view as? NSTableView, + bounds.intersects(convert(table.visibleRect, from: table)) + { + return table + } + for child in view.subviews { + if let table = find(in: child) { return table } + } + return nil + } + var ancestor = superview + while let view = ancestor { + if let table = find(in: view) { return table } + ancestor = view.superview + } + return nil + } +} + +struct ServerTableDropDelegate: DropDelegate { + let state: ServerTableDropState + let rows: [CatalogTreeRow] + let source: NodeSource + let workflow: CatalogWorkflow + let onDrop: ([String], NodeID?) -> Bool + + private enum Target { + case root + case group(NodeID) + + var id: NodeID? { + switch self { + case .root: nil + case .group(let id): id + } + } + } + + private func target(info: DropInfo) -> Target? { + guard source == .manual, info.hasItemsConforming(to: [.serverCatalogNode]), + let dragged = state.draggedID, + let row = state.reader?.row(at: info.location, expectedCount: rows.count) + else { return nil } + let target: Target + if row < 0 { + target = .root + } else { + guard rows.indices.contains(row), rows[row].node.isGroup else { return nil } + target = .group(rows[row].id) + } + return workflow.canMove(dragged, to: target.id) ? target : nil + } + + func validateDrop(info: DropInfo) -> Bool { target(info: info) != nil } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: target(info: info) == nil ? .forbidden : .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard let target = target(info: info), let expected = state.draggedID else { return false } + let providers = info.itemProviders(for: [.serverCatalogNode]) + guard providers.count == 1, let provider = providers.first else { return false } + provider.loadDataRepresentation( + forTypeIdentifier: UTType.serverCatalogNode.identifier + ) { data, _ in + guard let data, String(data: data, encoding: .utf8) == expected.rawValue else { return } + Task { @MainActor in + _ = onDrop([expected.rawValue], target.id) + } + } + return true + } +} diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServerSidebarRow.swift b/ShadowsocksX-NG2/App/UI/Servers/ServerTableNameCell.swift similarity index 82% rename from ShadowsocksX-NG2/App/UI/Servers/ServerSidebarRow.swift rename to ShadowsocksX-NG2/App/UI/Servers/ServerTableNameCell.swift index 70b9e4d6..c930c899 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ServerSidebarRow.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ServerTableNameCell.swift @@ -1,25 +1,9 @@ import SwiftUI -/// 服务器子视图侧栏的树行(issue #41):来源标识、有效性提示、活动目标标记 -/// 与右键菜单。数据来自目录工作流 module 的树 projection;激活经 seam 的 -/// typed command,活动目标标记由父视图传入(运行时事实,不进目录 projection)。 -/// 删除一律交父视图确认弹窗(空手动组单次、非空二次,GLOSSARY.md)。 -struct ServerSidebarRow: View { +/// Table 名称列:名称占剩余宽度,活动目标在列尾。 +struct ServerTableNameCell: View { let node: CatalogTreeNode - let workflow: CatalogWorkflow - /// 激活反馈共享状态:命令经它发出(单飞互斥、结果记录在会话反馈里)。 - let activation: ActivationFeedbackState let activeTargetID: NodeID? - let errors: ErrorAlertPresenter - let onEdit: (NodeID) -> Void - let onRename: (NodeID) -> Void - let onNewGroup: (NodeID?) -> Void - let onMove: (NodeID) -> Void - let onDuplicate: (NodeID) -> Void - let onDelete: (NodeID) -> Void - let onExport: (NodeID) -> Void - - private var isSubscription: Bool { node.source == .subscription } var body: some View { HStack(spacing: 6) { @@ -47,11 +31,28 @@ struct ServerSidebarRow: View { } } .frame(minHeight: 28) - .contextMenu { contextMenu } } +} + +/// 行级右键菜单沿用目录工作流的命令与资格事实。 +struct ServerNodeContextMenu: View { + let node: CatalogTreeNode + let workflow: CatalogWorkflow + /// 激活反馈共享状态:命令经它发出(单飞互斥、结果记录在会话反馈里)。 + let activation: ActivationFeedbackState + let errors: ErrorAlertPresenter + let onEdit: (NodeID) -> Void + let onRename: (NodeID) -> Void + let onNewGroup: (NodeID?) -> Void + let onDuplicate: (NodeID) -> Void + let onDelete: (NodeID) -> Void + let onExport: (NodeID) -> Void + + private var isSubscription: Bool { node.source == .subscription } + @ViewBuilder - private var contextMenu: some View { + var body: some View { Button("激活") { Task { @MainActor in do { @@ -89,8 +90,6 @@ struct ServerSidebarRow: View { onNewGroup(node.isGroup ? node.id : node.parentID) } Divider() - Button("移动到…") { onMove(node.id) } - Divider() Button("删除…", role: .destructive) { onDelete(node.id) } diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServerTableSort.swift b/ShadowsocksX-NG2/App/UI/Servers/ServerTableSort.swift new file mode 100644 index 00000000..a441ca0f --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Servers/ServerTableSort.swift @@ -0,0 +1,71 @@ +import Foundation + +/// 展示排序不改写目录顺序;未知时间在两个方向都排在最后。 +struct ServerTableSort: SortComparator { + enum Field: CaseIterable { + case name, createdAt, updatedAt + } + + let field: Field + var order: SortOrder + + init(_ field: Field, order: SortOrder = .forward) { + self.field = field + self.order = order + } + + func compare(_ lhs: CatalogTreeNode, _ rhs: CatalogTreeNode) -> ComparisonResult { + let result: ComparisonResult + switch field { + case .name: + result = lhs.name.localizedStandardCompare(rhs.name) + case .createdAt, .updatedAt: + let left = field == .createdAt ? lhs.createdAt : lhs.updatedAt + let right = field == .createdAt ? rhs.createdAt : rhs.updatedAt + switch (left, right) { + case (nil, nil): return .orderedSame + case (nil, _): return .orderedDescending + case (_, nil): return .orderedAscending + case (.some(let left), .some(let right)): result = left.compare(right) + } + } + guard order == .reverse else { return result } + switch result { + case .orderedAscending: return .orderedDescending + case .orderedDescending: return .orderedAscending + case .orderedSame: return .orderedSame + } + } + + static func sorted(_ nodes: [CatalogTreeNode], by sortOrder: [Self]) -> [CatalogTreeNode] { + guard let comparator = sortOrder.first else { return nodes } + return nodes.enumerated().sorted { lhs, rhs in + let result = comparator.compare(lhs.element, rhs.element) + return result == .orderedSame ? lhs.offset < rhs.offset : result == .orderedAscending + }.map(\.element) + } +} + +extension CatalogTreeSnapshot { + /// 来源不跨越子树,因此根层筛选保留完整层级。 + func roots(for source: NodeSource, sortedBy sortOrder: [ServerTableSort]) -> [CatalogTreeNode] { + ServerTableSort.sorted(roots.filter { $0.source == source }, by: sortOrder) + } + + /// 每层单独排序,再按展开状态生成 Table 行;不改变子树归属。 + func visibleRows( + source: NodeSource, sortedBy sortOrder: [ServerTableSort], collapsed: Set + ) -> [CatalogTreeRow] { + var rows: [CatalogTreeRow] = [] + func append(_ nodes: [CatalogTreeNode], depth: Int) { + for node in ServerTableSort.sorted(nodes, by: sortOrder) { + rows.append(CatalogTreeRow(node: node, depth: depth)) + if !collapsed.contains(node.id) { + append(node.childNodes, depth: depth + 1) + } + } + } + append(roots.filter { $0.source == source }, depth: 0) + return rows + } +} diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift b/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift index 060728cd..571ecab7 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift @@ -9,11 +9,12 @@ struct ServersView: View { /// 激活命令都经它发出。 let activation: ActivationFeedbackState /// 活动目标标记:父视图传入的运行时事实,不进目录 projection(与 - /// ServerSidebarRow/ServerDetailView 的传参先例同法)。 + /// ServerTableNameCell/ServerDetailView 的传参先例同法)。 let activeTargetID: NodeID? @Binding var selection: NodeID? - /// 分组折叠状态:组合根持有的共享对象(与首页目标树同源),跨 destination - /// 切换存续;OutlineGroup 的内建展开态会随分区切换丢失,侧栏因此自管折叠。 + @Binding var source: NodeSource + @Binding var sortOrder: [ServerTableSort] + /// 服务器管理的展开状态跨工作区页面切换保留,不落盘。 @ObservedObject var expansion: CatalogExpansionState let clipboard: any TextClipboard let imageClipboard: any ImageClipboard @@ -30,20 +31,22 @@ struct ServersView: View { @State private var isPresentingNewGroup = false @State private var serverFormOperation: ServerFormSheet.Operation? @State private var deleteTarget: NodeID? - @State private var moveTarget: NodeID? - @State private var rootDropHovering = false + @State private var dropState = ServerTableDropState() // 分享 popover 打开时冻结载荷、顶部资料与建议文件名。 @State var shareContext: ShareContext? var body: some View { - // 分隔位置不持久:HSplitView 无位置 API,切标签(destination 挂载制) - // 与重启都会回到 idealWidth,属接受的取舍。 - HSplitView { - serverSidebar - .frame(minWidth: 240, idealWidth: 280, maxWidth: 320) + NavigationSplitView { + sourceSidebar + .navigationSplitViewColumnWidth(min: 120, ideal: 150, max: 200) + } content: { + serverTable + .navigationSplitViewColumnWidth(min: 380, ideal: 520) + } detail: { serverDetailPane .frame(minWidth: 320, maxWidth: .infinity, maxHeight: .infinity) } + .navigationSplitViewStyle(.balanced) .frame(minHeight: 420) .sheet( item: Binding( @@ -70,13 +73,6 @@ struct ServersView: View { } message: { Text(deleteMessage) } - .sheet( - item: Binding( - get: { moveTarget.map(NodeContext.init) }, - set: { moveTarget = $0?.nodeID }) - ) { context in - MoveNodeSheet(workflow: workflow, errors: errors, nodeID: context.nodeID) - } .sheet(item: $serverFormOperation) { operation in ServerFormSheet( workflow: workflow, operation: operation, @@ -88,10 +84,77 @@ struct ServersView: View { } } - private var serverSidebar: some View { - List(selection: $selection) { - ForEach(workflow.tree.visibleRows(collapsed: expansion.collapsedGroupIDs)) { row in - treeRow(row) + private var sourceSidebar: some View { + List( + selection: Binding( + get: { source }, + set: { value in + guard let value, value != source else { return } + selection = nil + source = value + }) + ) { + Label("本地", systemImage: "internaldrive").tag(NodeSource.manual) + Label("订阅", systemImage: "arrow.triangle.2.circlepath").tag(NodeSource.subscription) + } + .listStyle(.sidebar) + } + + private var serverTable: some View { + let rows = workflow.tree.visibleRows( + source: source, sortedBy: sortOrder, collapsed: expansion.collapsedGroupIDs) + let depths = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0.depth) }) + return Table( + of: CatalogTreeNode.self, selection: $selection, + sortOrder: Binding(get: { sortOrder }, set: { sortOrder = Array($0.prefix(1)) }) + ) { + TableColumn("名称", sortUsing: ServerTableSort(.name)) { node in + HStack(spacing: 4) { + if node.isGroup && !node.childNodes.isEmpty { + Button { + selection = expansion.setExpanded( + expansion.isCollapsed(node.id), for: node, selection: selection) + } label: { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .rotationEffect(.degrees(expansion.isCollapsed(node.id) ? 0 : 90)) + .frame(minWidth: 24, minHeight: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(expansion.isCollapsed(node.id) ? "展开" : "收起") + } else { + Color.clear.frame(width: 24, height: 28) + } + ServerTableNameCell(node: node, activeTargetID: activeTargetID) + } + .padding(.leading, CGFloat(depths[node.id] ?? 0) * 14) + } + .width(min: 160, ideal: 240) + TableColumn("创建时间", sortUsing: ServerTableSort(.createdAt)) { node in + timestampCell(node.createdAt) + } + .width(min: 100, ideal: timestampColumnWidth) + TableColumn("修改时间", sortUsing: ServerTableSort(.updatedAt)) { node in + timestampCell(node.updatedAt) + } + .width(min: 100, ideal: timestampColumnWidth) + } rows: { + ForEach(rows) { row in + TableRow(row.node) + .itemProvider { + guard let payload = workflow.dragPayload(for: row.id) else { return nil } + return dropState.provider(for: row.id, payload: payload) + } + } + } + .contextMenu(forSelectionType: NodeID.self) { ids in + if let id = ids.first, let node = workflow.tree.node(withID: id) { + ServerNodeContextMenu( + node: node, workflow: workflow, activation: activation, errors: errors, + onEdit: presentEditServer, onRename: { renameTarget = $0 }, + onNewGroup: presentNewGroup, onDuplicate: duplicateNode, + onDelete: { deleteTarget = $0 }, onExport: exportConfigurationGroup) } } .onKeyPress(.escape) { @@ -99,8 +162,8 @@ struct ServersView: View { selection = nil return .handled } - .overlay(alignment: .center) { - if workflow.tree.isEmpty { + .overlay { + if rows.isEmpty { ContentUnavailableView( "暂无服务器", systemImage: "server.rack", description: Text("用工具栏的「新建服务器」按钮手动录入,或用「导入」菜单导入") @@ -108,72 +171,32 @@ struct ServersView: View { .allowsHitTesting(false) } } - .dropDestination(for: String.self) { payload, _ in - handleDrop(payload, onto: nil) - } isTargeted: { hovering in - rootDropHovering = hovering - } + .background { ServerTableDropReader(state: dropState) } + .onDrop( + of: [.serverCatalogNode], + delegate: ServerTableDropDelegate( + state: dropState, rows: rows, source: source, workflow: workflow, onDrop: handleDrop) + ) .toolbar { toolbarContent } } - private func treeRow(_ row: CatalogTreeRow) -> some View { - let node = row.node - return HStack(spacing: 4) { - if node.isGroup { - if node.childNodes.isEmpty { - Color.clear.frame(width: 11, height: 11) - } else { - Button { - withAnimation { expansion.toggleCollapsed(node.id) } - } label: { - Image(systemName: "chevron.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .rotationEffect(.degrees(expansion.isCollapsed(node.id) ? 0 : 90)) - } - .buttonStyle(.plain) - .help(expansion.isCollapsed(node.id) ? "展开" : "收起") - } - } - ServerSidebarRow( - node: node, - workflow: workflow, - activation: activation, - activeTargetID: activeTargetID, - errors: errors, - onEdit: presentEditServer, - onRename: { renameTarget = $0 }, - onNewGroup: { parent in - presentNewGroup(in: parent) - }, - onMove: { moveTarget = $0 }, - onDuplicate: duplicateNode, - onDelete: { deleteTarget = $0 }, - onExport: exportConfigurationGroup - ) - } - .padding(.leading, leadingInset(for: row)) - .tag(node.id) - .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 - } + private func timestampCell(_ date: Date?) -> some View { + Text(date.map { Self.timestampFormatter.string(from: $0) } ?? "—") + .lineLimit(1) + .foregroundStyle(.secondary) } - /// 层级缩进:分组行按深度缩进;叶子行额外让出箭头槽位与分组文本对齐。 - private func leadingInset(for row: CatalogTreeRow) -> CGFloat { - let depthStep: CGFloat = 14 - let chevronSlot: CGFloat = 15 - if row.node.isGroup { - return CGFloat(row.depth) * depthStep - } - return CGFloat(row.depth) * depthStep + chevronSlot + private static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() + + private var timestampColumnWidth: CGFloat { + let text = Self.timestampFormatter.string(from: Date()) + return ceil( + (text as NSString).size(withAttributes: [.font: NSFont.systemFont(ofSize: 13)]).width) + 24 } /// 删除结果 → selection invalidation(story 18):仅清除失效选择,不自动 @@ -187,12 +210,13 @@ struct ServersView: View { /// 落点语义:拖到分组行 = 移入该组(仅手动组接受),拖到列表空白/根 = 移到根。 /// 资格事实由 seam 提供;跨来源/成环仍由领域拒绝(不变量防线)。 private func handleDrop(_ payload: [String], onto target: NodeID?) -> Bool { - guard let raw = payload.first else { return false } + guard payload.count == 1, 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) + selectCreatedNode(dragged) } catch { errors.present(error) } @@ -242,6 +266,26 @@ extension ServersView { @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .primaryAction) { + Menu { + Picker( + "排序", + selection: Binding( + get: { sortOrder.first?.field }, + set: { field in sortOrder = field.map { [ServerTableSort($0)] } ?? [] }) + ) { + Text("无").tag(nil as ServerTableSort.Field?) + Text("名称").tag(ServerTableSort.Field.name as ServerTableSort.Field?) + Text("创建时间").tag(ServerTableSort.Field.createdAt as ServerTableSort.Field?) + Text("修改时间").tag(ServerTableSort.Field.updatedAt as ServerTableSort.Field?) + } + .pickerStyle(.inline) + } label: { + Label("排序", systemImage: "arrow.up.arrow.down") + } + .labelStyle(.iconOnly) + .help("排序") + } ToolbarItem(placement: .primaryAction) { Button { presentNewServer(in: workflow.importTargetParent(for: selection)) @@ -340,6 +384,7 @@ extension ServersView { } private func selectCreatedNode(_ id: NodeID) { + source = .manual expansion.reveal(id, in: workflow.tree) selection = id } @@ -394,7 +439,7 @@ extension ServersView { } } -/// moveTarget/renameTarget 的 sheet(item:) 适配壳。 +/// renameTarget 的 sheet(item:) 适配壳。 private struct NodeContext: Identifiable { let nodeID: NodeID var id: NodeID { nodeID } diff --git a/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift b/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift index 8007aad7..0306bc68 100644 --- a/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift @@ -36,6 +36,8 @@ struct MainWindowView: View { /// 更新期间发布;外部导航反向同步,route 仍是导航位置的唯一来源。 @State private var tabSelection: WorkspaceDestination = WorkspaceRoute.initialDestination @State private var selection: NodeID? + @State private var serverSource: NodeSource = .manual + @State private var serverSortOrder: [ServerTableSort] = [] /// 全局导入面板由窗口壳持有。 @State private var presentedWorkspaceSheet: WorkspaceSheet? @@ -63,6 +65,11 @@ struct MainWindowView: View { tabSelection = destination } } + .onChange(of: selection) { + guard let selection, let node = workflow.tree.node(withID: selection) else { return } + serverSource = node.source + expansion.reveal(selection, in: workflow.tree) + } .navigationTitle(route.destination.label) .frame(minWidth: 920, minHeight: 580) .sheet(item: $presentedWorkspaceSheet) { sheet in @@ -106,6 +113,8 @@ struct MainWindowView: View { activation: activationFeedback, activeTargetID: control.snapshot.activeTarget?.id, selection: $selection, + source: $serverSource, + sortOrder: $serverSortOrder, expansion: expansion, clipboard: clipboard, imageClipboard: imageClipboard, diff --git a/ShadowsocksX-NG2/Tests/Catalog/CatalogExpansionStateTests.swift b/ShadowsocksX-NG2/Tests/Catalog/CatalogExpansionStateTests.swift index e03ee71a..a00c2c7e 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/CatalogExpansionStateTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/CatalogExpansionStateTests.swift @@ -44,6 +44,27 @@ final class CatalogExpansionStateTests: XCTestCase { @MainActor struct CatalogCreationExpansionTests { + @Test + func collapsingAncestorSelectsThatGroupAndPreservesOtherExpansionState() throws { + var catalog = ConfigurationCatalog() + let outer = try catalog.addGroup("外层") + let inner = try catalog.addGroup("内层", to: outer) + let selected = try catalog.addGroup("选中项", to: inner) + let other = try catalog.addGroup("其他") + let tree = CatalogTreeSnapshot.build( + from: catalog, credentials: InMemoryCredentialStore(), plugins: NoManagedPluginProvider()) + let node = try #require(tree.node(withID: outer)) + let expansion = CatalogExpansionState() + expansion.toggleCollapsed(other) + + let selection = expansion.setExpanded(false, for: node, selection: selected) + + #expect(selection == outer) + #expect(expansion.collapsedGroupIDs == [outer, other]) + #expect(expansion.setExpanded(true, for: node, selection: outer) == outer) + #expect(expansion.collapsedGroupIDs == [other]) + } + @Test func revealingCreatedNodeExpandsAncestorsAndPreservesOtherCollapsedGroups() throws { var catalog = ConfigurationCatalog() diff --git a/ShadowsocksX-NG2/Tests/Catalog/CatalogTreeProjectionTests.swift b/ShadowsocksX-NG2/Tests/Catalog/CatalogTreeProjectionTests.swift index 363c00fa..09687f4a 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/CatalogTreeProjectionTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/CatalogTreeProjectionTests.swift @@ -1,3 +1,4 @@ +import Testing import XCTest @testable import ShadowsocksX_NG2 @@ -8,6 +9,7 @@ final class CatalogTreeProjectionTests: XCTestCase { private func server(_ id: String) -> CatalogTreeNode { CatalogTreeNode( id: NodeID(rawValue: id), name: id, isGroup: false, source: .manual, parentID: nil, + createdAt: nil, updatedAt: nil, invalidReasons: [], childCount: 0, subtreeNodeCount: 0, invalidDescendantCount: 0, children: nil) } @@ -15,6 +17,7 @@ final class CatalogTreeProjectionTests: XCTestCase { private func group(_ id: String, children: [CatalogTreeNode]) -> CatalogTreeNode { CatalogTreeNode( id: NodeID(rawValue: id), name: id, isGroup: true, source: .manual, parentID: nil, + createdAt: nil, updatedAt: nil, invalidReasons: [], childCount: children.count, subtreeNodeCount: children.reduce(0) { $0 + 1 + $1.subtreeNodeCount }, invalidDescendantCount: 0, children: children) @@ -69,3 +72,69 @@ final class CatalogTreeProjectionTests: XCTestCase { XCTAssertEqual(CatalogTreeSnapshot(roots: []).serverLeafCount, 0) } } + +struct CatalogTreeTimestampProjectionTests { + @Test + func treeExposesCommittedCreationAndModificationTimes() throws { + var catalog = ConfigurationCatalog() + let created = Date(timeIntervalSince1970: 100) + let modified = Date(timeIntervalSince1970: 200) + let group = try catalog.addGroup("原名", now: created) + try catalog.renameGroup(group, to: "新名", now: modified) + let tree = CatalogTreeSnapshot.build( + from: catalog, credentials: InMemoryCredentialStore(), plugins: NoManagedPluginProvider()) + + #expect(tree.node(withID: group)?.createdAt == created) + #expect(tree.node(withID: group)?.updatedAt == modified) + } +} + +struct ServerTableOrderingTests { + @Test + func hierarchySortsEachSiblingSetAndCollapseHidesOnlyThatSubtree() throws { + var catalog = ConfigurationCatalog() + let outer = try catalog.addGroup("Z 外层") + let root = try catalog.addGroup("A 根层") + let childZ = try catalog.addGroup("Z 子组", to: outer) + let childA = try catalog.addGroup("A 子组", to: outer) + let leaf = try catalog.addGroup("孙节点", to: childA) + let tree = CatalogTreeSnapshot.build( + from: catalog, credentials: InMemoryCredentialStore(), plugins: NoManagedPluginProvider()) + let rows = tree.visibleRows(source: .manual, sortedBy: [ServerTableSort(.name)], collapsed: []) + + #expect(rows.map(\.id) == [root, outer, childA, leaf, childZ]) + #expect(rows.map(\.depth) == [0, 0, 1, 2, 1]) + #expect( + tree.visibleRows( + source: .manual, sortedBy: [ServerTableSort(.name)], collapsed: [outer] + ).map(\.id) == [root, outer]) + #expect(tree.roots.map(\.id) == [outer, root]) + #expect(tree.node(withID: outer)?.childNodes.map(\.id) == [childZ, childA]) + } + + @Test(arguments: [SortOrder.forward, .reverse]) + func sourceFilteringAndTimeSortingKeepUnknownLastAndEqualValuesStable(order: SortOrder) { + func node(_ id: String, source: NodeSource = .manual, date: Date?) -> CatalogTreeNode { + CatalogTreeNode( + id: NodeID(rawValue: id), name: id, isGroup: true, source: source, parentID: nil, + createdAt: date, updatedAt: date, invalidReasons: [], childCount: 0, + subtreeNodeCount: 0, invalidDescendantCount: 0, children: []) + } + let tree = CatalogTreeSnapshot(roots: [ + node("unknown", date: nil), + node("newer", date: Date(timeIntervalSince1970: 200)), + node("equal-first", date: Date(timeIntervalSince1970: 100)), + node("equal-second", date: Date(timeIntervalSince1970: 100)), + node("subscription", source: .subscription, date: Date(timeIntervalSince1970: 50)), + ]) + let sorted = tree.roots(for: .manual, sortedBy: [ServerTableSort(.createdAt, order: order)]) + #expect( + sorted.map(\.id.rawValue) + == (order == .forward + ? ["equal-first", "equal-second", "newer", "unknown"] + : ["newer", "equal-first", "equal-second", "unknown"])) + #expect( + tree.roots(for: .manual, sortedBy: []).map(\.id.rawValue) + == ["unknown", "newer", "equal-first", "equal-second"]) + } +} diff --git a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPolicyTests.swift b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPolicyTests.swift index c70a664b..0ca14a14 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPolicyTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowPolicyTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import ShadowsocksX_NG2 -/// 目录政策 seam 的 interface 测试(issue #41):删除确认档位、移动目的地、 +/// 目录政策 seam 的 interface 测试(issue #41):删除确认档位、 /// 拖放/移动资格、激活资格与激活命令经假 adapter 的目标传递。只观察结构化 /// fact 与 typed command outcome,不锁定 SwiftUI。 @MainActor @@ -98,24 +98,6 @@ final class CatalogWorkflowPolicyTests: XCTestCase { XCTAssertFalse(workflow.canDelete(NodeID(rawValue: "ghost")), "节点不存在") } - // MARK: - 移动目的地 - - func testMoveDestinationsAreRootAndManualGroupsExcludingOwnSubtree() async throws { - let outer = try await workflow.createGroup(named: "外层", into: nil) - let inner = try await workflow.createGroup(named: "内层", into: outer) - let sibling = try await workflow.createGroup(named: "兄弟", into: nil) - let serverID = try await importServer() - - let destinations = workflow.moveDestinations(for: outer) - XCTAssertEqual(destinations.map(\.id), [nil, sibling], "排除自身子树(内层)") - XCTAssertEqual(destinations.map(\.depth), [0, 0]) - XCTAssertEqual(destinations.map(\.name), ["目录根", "兄弟"]) - - let forServer = workflow.moveDestinations(for: serverID) - XCTAssertTrue(forServer.map(\.id).contains(outer), "服务器可移入手动组") - XCTAssertTrue(forServer.map(\.id).contains(inner), "含嵌套手动组") - } - // MARK: - 移动/拖放资格 func testCanMoveRejectsSelfCycleAndSubscriptionTarget() async throws { @@ -124,11 +106,12 @@ final class CatalogWorkflowPolicyTests: XCTestCase { let serverID = try await importServer(into: groupID) XCTAssertFalse(workflow.canMove(groupID, to: groupID), "非自身") - XCTAssertTrue(workflow.canMove(groupID, to: nil), "可回根") + XCTAssertFalse(workflow.canMove(groupID, to: nil), "根层节点不得同父重排") XCTAssertFalse(workflow.canMove(groupID, to: nestedID), "目标在被拖子树内") XCTAssertTrue(workflow.canMove(nestedID, to: nil)) XCTAssertFalse(workflow.canMove(nestedID, to: nestedID)) XCTAssertTrue(workflow.canMove(serverID, to: nestedID)) + XCTAssertFalse(workflow.canMove(serverID, to: groupID), "已有父组不得同父重排") let fixture = try CatalogFixtures.makeSubscriptionFixture() try CatalogFileStore(fileURL: fileURL).save(CatalogDocument(catalog: fixture.catalog)) diff --git a/docs/design/server-three-column-table.md b/docs/design/server-three-column-table.md new file mode 100644 index 00000000..5f02b955 --- /dev/null +++ b/docs/design/server-three-column-table.md @@ -0,0 +1,79 @@ +# 服务器三栏视图 + +状态:实现与自动检查完成(2026-10-09);视觉及原生交互交给人工验证。 +日期:2026-10-09。 + +本文记录本次任务的需求与设计决定,不代表当前实现。 + +## 确定要求 + +- 服务器页面改为三栏 `NavigationSplitView`:Sidebar 为来源边栏,Content 为服务器列表,Detail 为原详情视图。 +- 不修改原服务器详情与配置组详情。 +- 来源边栏只有「本地」和「订阅」。主窗口原「订阅」管理页面保留。 +- 「本地」展示手动来源的根层节点与后代,不展示虚拟目录根。 +- 「订阅」展示订阅组与后代。 +- Content 用 SwiftUI `Table` 展示层级,类似 Finder 列表大纲。 +- 三列为「名称」「创建时间」「修改时间」。名称列含配置组/服务器图标、左对齐的名称,以及右对齐的活动目标图标。 +- 活动目标图标只标记目标节点本身,包含配置组目标,不级联标记后代。保留无效服务器与包含无效后代的配置组的警告图标。 +- 单击选择节点,激活继续使用现有命令。列表保持单选。 +- 切换来源清空节点选择,详情显示原有空状态。 +- 默认展示目录顺序。列头排序只改变展示顺序,不改写目录;每组的直接子节点分别排序。 +- 拖动仅改变父分组,不提供同级重排。移除右键菜单「移动到…」。其余现有操作保留,继续调用 `CatalogWorkflow`。 +- 时间采用系统本地化日期和时间,精度到分钟;未知时间显示「—」。时间事实遵循 GLOSSARY「节点时间戳」与 ADR-0031。 +- 拖放仅接受手动分组与「本地」列表空白;空白代表目录根。新父分组不同才执行移动,移动后追加到目标目录顺序末尾。服务器、订阅节点、自身及后代均不接受落点。 +- 列头排序时,分组和服务器一起排序;未知时间始终排在最后,相等值保持目录顺序。 +- 折叠包含选中节点的配置组时,选择切换到收起的配置组,详情同步切换。 +- 创建、导入或订阅组复制产生手动节点后,切换到「本地」,展开其祖先并选择新节点。 +- 支持列宽调整,固定显示三列。名称列占剩余宽度,时间列初始宽度按日期文本确定。 +- 使用系统边栏显隐按钮,Content 和 Detail 保持显示。 +- 来源、排序与展开状态在本次运行内跨主窗口页面切换保留,不落盘。重启恢复「本地」、目录顺序和全部展开。来源切换仍清空节点选择。 +- 英文文案为 `Local`、`Subscriptions`、`Name`、`Date Created`、`Date Modified`。 +- 增加列表排序菜单;「无」表示不施加展示排序、按目录顺序展示。英文为 `None`。列头继续切换对应列的升序与降序。 +- 拖动移动成功后展开目标的祖先和目标分组,选择移动的节点,详情同步显示该节点。移到根层时无需展开目标分组。 + +## 实施前代码核实 + +- `ServersView` 使用 `HSplitView` 和扁平可见行 `List`。 +- `CatalogTreeNode` 已包含来源、父节点与子树,但尚未包含创建时间与修改时间。 +- `CatalogEntry` 已保存两个可空时间戳,无需重新定义时间语义。 +- 现有目录移动命令支持同父重排;新列表必须拒绝同父拖放。 +- 当前服务器管理的展开状态跨工作区页面切换保留,不落盘,重启全部展开。 + +## 最终确认 + +用户确认全部交互决定,并批准「无」的英文 `None`。用户要求使用 implement 流程,完成后提交当前分支;视觉验证交给人工,不由 Agent 执行。 + +## Module 方向 + +沿用 `CatalogWorkflow` 的 Interface 提供目录事实与变更命令。列表展示排序不进入目录持久化。树投影补充非敏感时间事实。视图持有来源、排序、展开和选择的呈现状态。具体 Interface 在交互规则确定后核实,不增加仅转发调用的 Module。 + +浏览状态需由跨工作区页面切换仍存续的持有者管理;不能只放入随页面卸载的 `ServersView` 局部状态。来源与排序属于服务器页面呈现,不加入工作区顶层路由或持久配置。新 Table 直接使用节点身份驱动选择与展开,替换原扁平 List 呈现。保留原详情 View 与现有变更工作流。 + +## 文档范围 + +本次沿用配置目录、服务器配置、配置组、手动组、订阅组、活动目标与节点时间戳的既有定义,不新增领域术语。本次布局与呈现决定可以局部修改,不新增 ADR。 + +## 实现验证范围 + +- 核实非敏感树投影中的时间戳与来源筛选。 +- 验证每层排序、未知时间、相等值及目录顺序恢复。 +- 验证同父拖放不提交目录变更,跨父移动仍使用现有工作流。 +- 构建 macOS App,检查原详情与现有操作的编译集成。 +- 视觉与原生交互由人工验证:三栏、层级 Table、列宽、选择、展开、排序菜单与拖放。Agent 不执行视觉验证,构建与 hostless 测试不替代该证据。 + +## 实现期间的补充决定 + +- 排序按钮直接展开字段列表;Picker 使用 inline 样式,不产生额外「排序」子菜单。 +- 用户要求只给树展开图标设置最小宽度。名称列的展开按钮设置最小宽度 24 点、最小高度 28 点;图标不放大。 +- Table 的行由树投影按来源、每层展示排序与展开状态生成,名称列按深度缩进。展开按钮显式绑定既有展开状态,以控制点击宽度和折叠选择规则。 +- `TableRowContent.dropDestination` 不提供拒绝返回值,因此不使用该行级接口。统一 `DropDelegate` 借用原生 `NSTableView.row(at:)` 区分行与空白;无效行不得回退为根落点。AppKit Adapter 不替换 Table 或其 delegate。 +- 目录节点拖放使用本进程专用载荷类型,释放时再次核对载荷身份与移动资格。 + +## 验证记录(2026-10-09) + +- 定向验证树投影时间戳、来源筛选、每层排序、未知时间最后、相等值稳定、折叠选择和同父移动拒绝。 +- Debug 构建通过;全量 hostless 摘要为 976 个测试通过、0 失败、0 跳过,包含 1062 次参数化执行。 +- 两组 Python 离线检查分别通过 4 项与 17 项。 +- 格式化和 diff 检查通过;SwiftLint 为 22 条非严重警告,严重违规为 0。ServersView 文件长度仍有警告。 +- code-review 的 Standards 与 Spec 两轴最终均为 0 项发现;初次发现的无效行落点问题在复核中关闭。 +- 原生拖放命中、菜单呈现、展开按钮点击区域与三栏视觉未验证,按用户要求由人工验证。