import UIKit /// T-iPad-3 · 终端面板的指针(次要点击/右键)· 长按上下文菜单。 /// /// 纯菜单模型(可单测)+ `UIContextMenuInteraction` 桥。每个动作都**复用既有 /// 通道**,绝不新增网络路径: /// - `.copySelection` → SwiftTerm 自带的 `copy(_:)`(读选区 → 剪贴板,绝不改 /// 字节流,纯 UI —— hover 高亮同理); /// - `.newInCwd` → T-iOS-29 的 `onNewInCwd`(当前会话 cwd fresh spawn); /// - `.kill` → `onKill`(wiring 侧走 `APIClient.killSession` —— 带 /// Origin 的 G 端点,RO/G 铁律不变)。 /// /// 指针菜单仅在 iPad idiom 启用(`isPointerMenuEnabled`)—— iPhone 长按仍归 /// SwiftTerm 选区手势,字节级零回归。 /// One context-menu action, each mapped to an existing channel (no new path). enum TerminalContextAction: String, CaseIterable, Sendable, Equatable { case copySelection case newInCwd case kill } /// Display spec for one menu row (Chinese copy + SF Symbol + destructive flag). struct TerminalContextItem: Equatable, Sendable { let action: TerminalContextAction let title: String let systemImage: String let isDestructive: Bool } enum TerminalContextMenu { /// 用户可见文案(named constants,plan §4)。 enum Copy { static let copySelection = "复制选区" static let newInCwd = "在当前目录开新会话" static let kill = "结束会话" } private enum Symbol { static let copySelection = "doc.on.doc" static let newInCwd = "plus.rectangle.on.folder" static let kill = "xmark.circle" } /// 指针上下文菜单仅在 iPad 启用。设备 idiom 与 size class 正交(不属 /// `LayoutPolicy` 的 stack/split 判据),故独立成谓词;iPhone 上不安装 /// interaction,长按保持 SwiftTerm 原生选区手势(零回归)。 static func isPointerMenuEnabled(idiom: UIUserInterfaceIdiom) -> Bool { idiom == .pad } /// 依可用性过滤出菜单项(固定顺序:复制选区 → 开新会话 → 结束会话)。纯函数。 static func items( canCopySelection: Bool, canNewInCwd: Bool, canKill: Bool ) -> [TerminalContextItem] { var result: [TerminalContextItem] = [] if canCopySelection { result.append(TerminalContextItem( action: .copySelection, title: Copy.copySelection, systemImage: Symbol.copySelection, isDestructive: false )) } if canNewInCwd { result.append(TerminalContextItem( action: .newInCwd, title: Copy.newInCwd, systemImage: Symbol.newInCwd, isDestructive: false )) } if canKill { result.append(TerminalContextItem( action: .kill, title: Copy.kill, systemImage: Symbol.kill, isDestructive: true )) } return result } } /// 上下文菜单的可测装配核:把可用性 + 既有通道闭包收拢成 `items` 与 `perform`。 /// wiring 供给 `onNewInCwd`(T-iOS-29)/ `onKill`(APIClient.killSession); /// `onCopySelection`/`hasSelection` 由终端视图注入。动作 ↔ 通道一一映射,无旁路。 @MainActor struct TerminalContextMenuModel { let onNewInCwd: (@MainActor () -> Void)? let onKill: (@MainActor () -> Void)? let onCopySelection: @MainActor () -> Void let hasSelection: @MainActor () -> Bool /// 当前可用的菜单项:复制选区随实时选区、开新会话/结束会话随 wiring 是否 /// 供给了对应闭包(无闭包 = 隐藏,绝不呈现死项)。 var items: [TerminalContextItem] { TerminalContextMenu.items( canCopySelection: hasSelection(), canNewInCwd: onNewInCwd != nil, canKill: onKill != nil ) } /// 把一个动作路由到它对应的既有通道(映射的唯一实现点)。 func perform(_ action: TerminalContextAction) { switch action { case .copySelection: onCopySelection() case .newInCwd: onNewInCwd?() case .kill: onKill?() } } } /// `UIContextMenuInteraction` 委托:菜单展开时用 `makeModel()` 取当前快照 /// (实时选区/闭包),构建 `UIMenu`,每个 `UIAction` 触发 `model.perform`。 /// 仅在 iPad 由终端视图安装(见 `TerminalContextMenu.isPointerMenuEnabled`)。 @MainActor final class TerminalContextMenuInteractionDelegate: NSObject, UIContextMenuInteractionDelegate { private let makeModel: @MainActor () -> TerminalContextMenuModel init(makeModel: @escaping @MainActor () -> TerminalContextMenuModel) { self.makeModel = makeModel } func contextMenuInteraction( _ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint ) -> UIContextMenuConfiguration? { let model = makeModel() let items = model.items guard !items.isEmpty else { return nil } // 无可用项 → 不弹菜单 return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in UIMenu(title: "", children: items.map { item in UIAction( title: item.title, image: UIImage(systemName: item.systemImage), attributes: item.isDestructive ? .destructive : [] ) { _ in model.perform(item.action) } }) } } }