## 根因
tmux 对不存在的 `-c` 目录**返回 0 并悄悄用家目录**,`open /不存在` 则
静默什么都不做。实测:
tmux new-session -d -s t -c /不存在 ; echo $? → 0
pane_current_path → /Users/dev
projects.yaml 里 40 条有 6 条指向已删除的目录(agentx-agent 那一批),
于是表现成「Finder / VS Code 全没反应」+「Claude 开到 /Users/dev」——
看起来像刚加的右键菜单坏了,实际是数据坏了。
两个静默回落把数据问题伪装成了功能问题。我连猜三轮代码都没猜中,
最后靠"派发参数落盘 + tmux 实测"定位。
## 改动
- TerminalDispatch.open 派发前查 fileExists(isDirectory:),不通过就拒绝
- 派发日志写 launch.log(log show 对这个 app 是瞎的,ad-hoc 签名)
- hubprobe launch <项目名> [mode]:走完全相同那条路,把"界面传了哪个项目"
和"派发本身对不对"分开,这次就是没有它才只能猜
- ProjectStore.missingPaths:失效项目在两个界面上都标出来(岛上尤其必要,
那里根本不显示路径),菜单只留「从列表移除」——目录没了时每一个"打开"
类选项都是静默失败,给假的可点项等于把刚修好的问题再造一遍
- ProjectStore.remove / 工具栏「清理失效 N」:只动清单,不碰磁盘
- ProjectStore.addExisting + 「添加目录」:扫描只认 .git,monorepo 子目录和
没 git init 的目录永远进不来(实机 7 条),这是缺的那个入口
删除是文本手术,不是解析完重写 —— 保住手写的注释/别名/描述。
边界是删到下一个同级列表项或顶层键;只删 name 那行会让剩下的字段被挂到
前一条项目上,删一条弄坏两条且完全静默。11 个测试钉着。
518 个测试全过。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
120 lines
4.3 KiB
Swift
120 lines
4.3 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
/// 在鼠标位置弹一个原生 NSMenu。
|
||
///
|
||
/// 为什么不用 SwiftUI 的 Menu 做"整行点击弹菜单":borderless menu 会把大
|
||
/// label 压成内容自适应宽度(行布局全毁);透明盖层方案(Color.clear 当
|
||
/// label)在 macOS 上收不到点击。两条路都实测踩坑之后,回到 AppKit ——
|
||
/// NSMenu.popUpContextMenu 不依赖 SwiftUI 的 hit-testing,也不需要面板是
|
||
/// key window。
|
||
@MainActor
|
||
enum RowMenu {
|
||
|
||
struct Item {
|
||
let title: String
|
||
let action: () -> Void
|
||
|
||
init(_ title: String, action: @escaping () -> Void) {
|
||
self.title = title
|
||
self.action = action
|
||
}
|
||
}
|
||
|
||
/// items 里 nil 表示分隔线。
|
||
static func present(_ items: [Item?]) {
|
||
let menu = NSMenu()
|
||
for item in items {
|
||
if let item {
|
||
menu.addItem(ClosureMenuItem(title: item.title, handler: item.action))
|
||
} else {
|
||
menu.addItem(.separator())
|
||
}
|
||
}
|
||
guard let event = NSApp.currentEvent,
|
||
let view = event.window?.contentView
|
||
else { return }
|
||
NSMenu.popUpContextMenu(menu, with: event, for: view)
|
||
}
|
||
}
|
||
|
||
/// 右键(以及 ctrl+左键)捕获层。
|
||
///
|
||
/// ## 为什么不用 `.contextMenu`
|
||
///
|
||
/// SwiftUI 的 `.contextMenu` 在岛上不可靠 —— 岛是个基本不做 key window 的
|
||
/// NSPanel,而 SwiftUI 的菜单要走它自己那套 hit-testing。同一个坑在
|
||
/// `RowMenu` 的注释里已经记了一次(borderless Menu 压扁布局、透明盖层
|
||
/// 收不到点击)。这里直接用 AppKit 收事件,和 `RowMenu.present` 是同一条路。
|
||
///
|
||
/// ## 为什么要重写 `hitTest`
|
||
///
|
||
/// 盖在整行上的 NSView 默认会把**左键**也吃掉,那样整行点击(跳转会话 /
|
||
/// 起一个新的)就全废了 —— 加了个右键菜单反而把主功能弄没了。
|
||
/// 所以只在当前事件是右键、或 ctrl+左键(macOS 的等价操作)时才认领点击,
|
||
/// 其余一律返回 nil 让它穿透下去。
|
||
struct RightClickCatcher: NSViewRepresentable {
|
||
let onRightClick: () -> Void
|
||
|
||
func makeNSView(context: Context) -> CatcherView {
|
||
let view = CatcherView()
|
||
view.onRightClick = onRightClick
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ view: CatcherView, context: Context) {
|
||
view.onRightClick = onRightClick
|
||
}
|
||
|
||
final class CatcherView: NSView {
|
||
var onRightClick: (() -> Void)?
|
||
|
||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||
guard let event = NSApp.currentEvent else { return nil }
|
||
switch event.type {
|
||
case .rightMouseDown, .rightMouseUp, .rightMouseDragged:
|
||
return super.hitTest(point)
|
||
case .leftMouseDown, .leftMouseUp:
|
||
// **不能写成 `case .leftMouseDown, .leftMouseUp where …`** ——
|
||
// Swift 里 `where` 只作用于最后一个 pattern,`.leftMouseDown`
|
||
// 会无条件命中,于是这一层把所有左键都吃掉,整行点击全废。
|
||
// 编译器会警告,别把这条警告当噪音。
|
||
return event.modifierFlags.contains(.control)
|
||
? super.hitTest(point) : nil
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
override func rightMouseDown(with event: NSEvent) {
|
||
onRightClick?()
|
||
}
|
||
|
||
/// ctrl+左键。macOS 上单键鼠标/触控板的人用的就是这个,
|
||
/// 只认 rightMouseDown 会让他们以为功能没做。
|
||
override func mouseDown(with event: NSEvent) {
|
||
guard event.modifierFlags.contains(.control) else {
|
||
super.mouseDown(with: event)
|
||
return
|
||
}
|
||
onRightClick?()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 带闭包的菜单项。NSMenu 持有 item、item 以自身为 target —— 菜单收起后一起释放。
|
||
private final class ClosureMenuItem: NSMenuItem {
|
||
private let handler: () -> Void
|
||
|
||
init(title: String, handler: @escaping () -> Void) {
|
||
self.handler = handler
|
||
super.init(title: title, action: #selector(invoke), keyEquivalent: "")
|
||
target = self
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init(coder: NSCoder) { fatalError("not supported") }
|
||
|
||
@objc private func invoke() { handler() }
|
||
}
|