Files
wuguanganandClaude Opus 5 bfc4ebaecb fix: 失效项目静默开到家目录;补上删除和"添加任意目录"
## 根因

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>
2026-08-14 15:58:14 +08:00

120 lines
4.3 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() }
}