refactor(servers): use three-column table navigation

This commit is contained in:
Qiu Yuzhou
2026-10-09 19:07:44 +08:00
parent f23a24f203
commit 801348c5b6
14 changed files with 632 additions and 219 deletions
@@ -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 {
@@ -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 {
@@ -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"
@@ -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
@@ -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)
}
}
}
}
@@ -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
}
}
@@ -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)
}
@@ -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<NodeID>
) -> [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
}
}
+131 -86
View File
@@ -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<NodeSource?>(
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<ServerTableSort.Field?>(
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 }
@@ -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,
@@ -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()
@@ -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"])
}
}
@@ -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))
+79
View File
@@ -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 项发现;初次发现的无效行落点问题在复核中关闭。
- 原生拖放命中、菜单呈现、展开按钮点击区域与三栏视觉未验证,按用户要求由人工验证。