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>
This commit is contained in:
wuguanganandClaude Opus 5 committed 2026-08-14 15:58:14 +08:00
1 parent ed287a992e
commit bfc4ebaecb
11 files changed
+761 -32

No files matched your search

+45
View File
@@ -311,6 +311,51 @@ claude 的父进程是 tmux server,关窗口只是 detach,进程照跑。于
- **连续两轮才动手**(30 秒)。一轮就杀的话,换窗口、iTerm 重启恢复
会话这类正常的短暂 detach 都会被当成关掉了。
### tmux 对不存在的 `-c` 目录返回 0,然后悄悄用家目录
```
$ tmux new-session -d -s t -c /不存在的路径 ; echo $?
0
$ tmux list-panes -t t -F '#{pane_current_path}'
/Users/dev
```
`new-window -c` 同样如此。`open /不存在的路径` 则静默什么都不做。
**这两个静默回落把一个数据问题彻底伪装成了功能问题。** 实机现象是
「右键菜单里 Finder / VS Code 全没反应」+「Claude Code 开到 `/Users/dev`」,
看起来像刚加的右键菜单写坏了。真实原因是 projects.yaml 里 40 条有 6 条
指向已经被删掉的目录(agentx-agent 那一批)。
我为此连猜三轮代码("path 传成空字符串了吧")都没猜中,直到把派发参数
落盘 + 拿 tmux 实测才定位到。教训有两条:
1. **不报错的 API 要主动查前置条件。** `TerminalDispatch.open` 现在在派发前
`fileExists(isDirectory:)`,不通过就拒绝并记日志。
2. **看不见的东西查不了。** `log show` 对这个 app 是瞎的(ad-hoc 签名,
os.Logger 打了看不到),所以派发日志写
`~/Library/Application Support/claude-hub/launch.log`。
另有 `hubprobe launch <项目名> [mode]` 走完全相同的那条路,
用来把「界面传了哪个项目」和「派发本身对不对」分开。
### 扫描只认 `.git`,所以有一整类项目永远进不来
`GitStatus.scanRepositories` 撞到 `.git` 就停止下钻,且只收有 `.git` 的目录。
于是 monorepo 里的子目录(`demo-iot/{ios,android,server}`)、还没
git init 的目录(`fetch`、`game`)全都扫不进来 —— 实机 40 条里有 7 条属于
这一类,只能手写 yaml,而且写进去之后**没有任何界面入口能删掉**。
补的是两头:`ProjectStore.addExisting(path:)`(选任意目录登记,配 NSOpenPanel)
和 `ProjectStore.remove(_:)`。
删除是**文本手术**(`ProjectYAML.removing(names:from:)`),不是"解析完重写":
这份文件是给人手改的,重写会把注释、别名、描述、分段空行全抹平 ——
`AcceptanceStore` 和 `SessionWatchdog` 都因为"内存状态直接盖盘"弄丢过手改内容。
删除的边界:从 `- name: X` 一直删到**下一个同级列表项或下一个顶层键**。
只删 name 那一行的话,剩下的 `path:` / `aliases:` 会被解析器挂到**前一条**
项目上 —— 删一条把另一条也弄坏了,且完全静默。测试钉着这一条。
## 打包与签名
### 辅助可执行文件不能放 `Contents/MacOS/`
+46 -1
View File
@@ -264,6 +264,42 @@ private func describe(_ reason: StallReason) -> String {
}
}
/// 走和 app 完全相同的那条派发路:ProjectStore → Project.expandedPath →
/// TerminalDispatch.open。
///
/// 加这个是为了把「界面把哪个项目传下来了」和「派发本身对不对」分开 ——
/// 实机上撞到过右键开出来的 claude 落在家目录,只看现象没法区分这两半,
/// 而没有 UI 的复现手段时只能靠读代码猜,猜了三轮没猜中。
@MainActor
func runLaunch(name: String, mode raw: String) {
let store = ProjectStore()
store.load()
let needle = name.lowercased()
guard let project = store.projects.first(where: {
$0.name.lowercased() == needle
|| $0.aliases.contains { $0.lowercased() == needle }
}) else {
print("找不到项目:\(name)(共 \(store.projects.count) 个)")
exit(1)
}
guard let mode = LaunchMode(rawValue: raw) else {
print("未知 mode:\(raw)。可选:\(LaunchMode.allCases.map(\.rawValue).joined(separator: ", "))")
exit(1)
}
print("项目 \(project.name)")
print(" yaml 原文 path:\(project.path.isEmpty ? "<空>" : project.path)")
print(" expandedPath :\(project.expandedPath.isEmpty ? "<空>" : project.expandedPath)")
print(" mode :\(mode.rawValue)")
let ok = TerminalDispatch().open(
project: project.name, path: project.expandedPath, mode: mode
)
print(ok ? "派发成功" : "派发失败(详见 launch.log)")
}
let args = Array(CommandLine.arguments.dropFirst())
switch args.first {
case "list", nil:
@@ -278,7 +314,16 @@ case "jump":
runJump(args[1])
case "usage":
runUsage(args.count >= 2 ? args[1] : nil)
case "launch":
guard args.count >= 2 else {
print("用法:hubprobe launch <项目名> [claude|terminal|finder|vscode|resumeLast]")
exit(1)
}
runLaunch(name: args[1], mode: args.count >= 3 ? args[2] : "finder")
default:
print("用法:hubprobe [list | jump <序号或 sessionId> | usage [7|30|all] | stall [--ai]]")
print("""
用法:hubprobe [list | jump <序号或 sessionId> | usage [7|30|all] \
| stall [--ai] | launch <项目名> [mode]]
""")
exit(1)
}
+40
View File
@@ -121,6 +121,46 @@ public enum ProjectYAML {
analyze(text).projects
}
/// 从 yaml 原文里删掉若干条项目,其余**一字不动**。
///
/// ## 为什么是文本手术而不是"解析完重写"
///
/// 重写会把用户手写的注释、别名、描述、分段空行全部抹平。这份文件是给人
/// 改的 —— `AcceptanceStore` 和 `SessionWatchdog` 都因为"内存状态直接盖盘"
/// 弄丢过手改内容(见它们的注释),同一个错不能犯第三次。
///
/// ## 删除的边界
///
/// 从 `- name: X` 那一行开始,一直删到**下一个同级列表项**或**下一个顶层键**
/// 为止。中间的 path / aliases / description / 注释 / 空行都属于这一条。
/// 不这么切的话会留下几行没主的 `path:`,而那些孤儿行会被解析器
/// 挂到**前一条**项目上 —— 删一条把另一条也弄坏了,且完全静默。
public static func removing(names: Set<String>, from text: String) -> String {
guard !names.isEmpty else { return text }
let lines = text.components(separatedBy: "\n")
var result: [String] = []
var dropping = false
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
let isTopLevel = !line.hasPrefix(" ") && !line.hasPrefix("\t") && !trimmed.isEmpty
let isEntry = trimmed.hasPrefix("- ")
if isTopLevel {
// 顶层键(`scan_dirs:` 之类)一定是新段落的开始。
dropping = false
} else if isEntry {
// 每遇到一个列表项就重新判定 —— 上一条删没删完到这里为止。
let rest = String(trimmed.dropFirst(2))
dropping = value(of: "name", in: rest).map(names.contains) ?? false
}
if !dropping { result.append(line) }
}
return result.joined(separator: "\n")
}
public static func analyze(_ text: String) -> Outcome {
var projects: [Project] = []
var entryMarkers = 0
+90 -4
View File
@@ -120,6 +120,7 @@ public final class ProjectStore {
sourceURL = url
projects = outcome.projects
rebuildPathIndex()
refreshMissing()
refreshGit()
return
}
@@ -137,6 +138,87 @@ public final class ProjectStore {
rebuildPathIndex()
}
// MARK: - 失效项目
/// 目录已经不在了的项目(key 是 `Project.id`)。
///
/// ## 为什么必须显式标出来
///
/// tmux 对不存在的 `-c` 目录**返回 0 并静默回落到家目录**,`open` 则
/// 什么都不做。所以一条指向已删除目录的记录,表现出来是
/// 「Finder 没反应」+「Claude 开到家目录」——看起来像界面坏了。
/// 实机上 40 条里有 6 条是这样的,而列表上一点痕迹都没有。
public private(set) var missingPaths: Set<String> = []
public func isMissing(_ project: Project) -> Bool { missingPaths.contains(project.id) }
/// 重新检查每个项目的目录还在不在。
///
/// 跟着 git 轮询走:40 次 stat 是微秒级的,而"目录被删了"这件事
/// 本来就该在下一次刷新时就看见,不该等重启。
private func refreshMissing() {
var missing: Set<String> = []
for project in projects {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(
atPath: project.expandedPath, isDirectory: &isDirectory
)
if !exists || !isDirectory.boolValue { missing.insert(project.id) }
}
if missing != missingPaths { missingPaths = missing }
}
// MARK: - 增删
/// 把项目从 yaml 里删掉。
///
/// **只动列表,不碰磁盘上的目录。** 用户要清理的是这份清单里的脏数据,
/// 不是他的代码 —— 一个"移除"按钮顺手删了工作区是不可接受的。
@discardableResult
public func remove(_ projects: [Project]) -> Bool {
guard !projects.isEmpty, let url = sourceURL,
let text = try? String(contentsOf: url, encoding: .utf8)
else { return false }
let names = Set(projects.map(\.name))
let updated = ProjectYAML.removing(names: names, from: text)
guard updated != text else { return false }
guard (try? updated.write(to: url, atomically: true, encoding: .utf8)) != nil
else { return false }
load()
return true
}
/// 一键清掉全部失效项目。
public var missingProjects: [Project] { projects.filter { missingPaths.contains($0.id) } }
/// 手工把一个已有目录登记成项目。
///
/// **扫描只认有 `.git` 的目录**,所以 monorepo 里的子目录、还没 git init
/// 的目录永远扫不进来 —— 实机上 40 条里有 7 条属于这种,只能手写 yaml。
/// 这是"能加载进去"这件事真正缺的那个入口。
@discardableResult
public func addExisting(path rawPath: String, name rawName: String? = nil) -> Bool {
let path = normalize(rawPath)
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory),
isDirectory.boolValue
else { return false }
// 已经登记过就别加第二遍 —— 重名条目会让"删除"变得没法预测。
guard !projects.contains(where: { normalize($0.expandedPath) == path }) else {
return false
}
guard let url = sourceURL ?? candidateURLs.first else { return false }
let trimmed = rawName?.trimmingCharacters(in: .whitespaces) ?? ""
let name = trimmed.isEmpty ? (path as NSString).lastPathComponent : trimmed
Self.append(entries: [(name, path)], to: url)
load()
return true
}
private func rebuildPathIndex() {
pathIndex = [:]
for project in projects {
@@ -249,13 +331,17 @@ public final class ProjectStore {
/// 写条目,产出的 YAML 变成"空数组标记后面跟着 117 条内容" ——
/// 解析器看到 `[]` 就认定项目段是空的,全部忽略。
nonisolated static func append(paths: [String], to url: URL) {
guard var text = try? String(contentsOf: url, encoding: .utf8) else { return }
append(entries: paths.map { (($0 as NSString).lastPathComponent, $0) }, to: url)
}
nonisolated static func append(entries: [(name: String, path: String)], to url: URL) {
guard !entries.isEmpty,
var text = try? String(contentsOf: url, encoding: .utf8) else { return }
text = normalizeProjectsKey(in: text)
if !text.hasSuffix("\n") { text += "\n" }
for path in paths {
let name = (path as NSString).lastPathComponent
text += " - name: \(name)\n path: \(path)\n"
for entry in entries {
text += " - name: \(entry.name)\n path: \(entry.path)\n"
}
try? text.write(to: url, atomically: true, encoding: .utf8)
}
@@ -144,6 +144,36 @@ public struct TerminalDispatch: Sendable {
mode: LaunchMode,
sessionId: String? = nil
) -> Bool {
// **每一次派发都记一笔。**
//
// 「点了没反应」和「开到了别的目录」这两类故障,在外面看起来都只是
// "结果不对",而中间传了什么完全不可见 —— `log show` 对这个 app 是瞎的
//(ad-hoc 签名,NOTES.md 记过),os.Logger 打了也看不到。
// 没有这份记录时我只能靠读代码猜 path 怎么会变空,猜了三轮没猜中。
Self.trace("open mode=\(mode.rawValue) name=\(name) path=\(path.isEmpty ? "<空>" : path)")
// **目录不存在必须当场拦下,不能让它往下走。**
//
// tmux 对不存在的 `-c` 目录**返回 0 并静默回落到家目录** ——
// 实测 `tmux new-session -c /不存在的路径` exit=0,
// `pane_current_path` 变成 `/Users/dev`。`open /不存在` 则什么都不做。
// 两者都不报错。
//
// 于是 projects.yaml 里 6 条指向已删除目录的记录,表现成
// 「Finder 那些全没用」+「Claude 开到家目录」,看起来像右键菜单坏了,
// 而真正坏的是数据。我为此猜了三轮代码都没猜中 ——
// **一个静默回落的 API 会把数据问题伪装成功能问题。**
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
guard !path.trimmingCharacters(in: .whitespaces).isEmpty else {
Self.trace("拒绝:\(name) 的路径是空的(检查 projects.yaml)")
return false
}
guard exists, isDirectory.boolValue else {
Self.trace("拒绝:\(name) 的目录不存在 —— \(path)")
return false
}
switch mode {
case .finder:
return Shell.run("/usr/bin/open", [path], timeout: 5).succeeded
@@ -171,6 +201,29 @@ public struct TerminalDispatch: Sendable {
return createSession(name: name, path: path, command: inner)
}
/// 派发日志。
///
/// 写文件而不是 `os.Logger`:本机实测 `log show` 完全看不到这个 app 的输出
/// (多半和 ad-hoc 签名有关),同 `SessionWatchdog` 的心跳。
/// **一个看不见的仪表比没有仪表更糟** —— 我拿它做过诊断,得出了错误结论。
static func trace(_ line: String) {
let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub/launch.log")
let stamp = ISO8601DateFormatter().string(from: Date())
guard let data = "\(stamp) \(line)\n".data(using: .utf8) else { return }
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
if let handle = try? FileHandle(forWritingTo: url) {
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
} else {
try? data.write(to: url)
}
}
/// 补齐项目的 `.claude/settings.json`(防污染 deny + 全套 hook)。
///
/// 脚本自己是幂等的、也自己兜底不阻断,所以这里失败了什么都不用做 ——
+25 -20
View File
@@ -1022,9 +1022,11 @@ struct ProjectListContent: View {
resumable: closedSessions.latest(
forProject: project.expandedPath
),
isMissing: projects.isMissing(project),
onOpen: { open(project) },
onLaunch: { onLaunch(project, $0) },
onTogglePin: { projects.togglePin(project) }
onTogglePin: { projects.togglePin(project) },
onRemove: { projects.remove([project]) }
)
}
}
@@ -1083,9 +1085,13 @@ struct ProjectRowView: View {
/// 上一个被「关窗即结束」收掉、且接得回来的会话。
/// 有它就说明这个项目"关掉了但没干完",菜单第一项应该是接回去而不是重开。
var resumable: ClosedSession?
/// 目录已经不在了。岛上的行不显示路径,所以这一档**必须有视觉标记** ——
/// 否则一个指向已删除目录的项目看起来和正常的一模一样。
var isMissing: Bool = false
let onOpen: () -> Void
let onLaunch: (LaunchMode) -> Void
let onTogglePin: () -> Void
var onRemove: (() -> Void)?
@State private var isHovered = false
@@ -1097,11 +1103,15 @@ struct ProjectRowView: View {
var body: some View {
rowBody
.contentShape(Rectangle())
// 右键盖在整行上。只认右键和 ctrl+左键,左键照旧穿透下去 ——
// 见 RightClickCatcher 里 hitTest 的说明。
.overlay(RightClickCatcher { presentLaunchMenu() })
.onTapGesture {
// 有会话:跳过去。无会话:在鼠标位置弹原生启动菜单 ——
// SwiftUI Menu 的两种整行方案都在 macOS 上翻过车
// (label 被压扁 / 透明盖层收不到点击),见 RowMenu 注释。
if running.isEmpty {
// 目录没了就别去开 —— tmux 会静默开在家目录。弹菜单让人移除。
if running.isEmpty || isMissing {
presentLaunchMenu()
} else {
onOpen()
@@ -1111,24 +1121,15 @@ struct ProjectRowView: View {
.help(project.expandedPath)
}
/// 启动菜单:LaunchMode 全集 + 置顶。整行点击和 "..." 共用。
/// 启动菜单。整行点击(无会话时)、右键、"..." 三个入口共用,
/// 内容也和主窗口共用同一份(`ProjectMenu`)。
private func presentLaunchMenu() {
var items: [RowMenu.Item?] = []
// 「接着上次」排第一,且只在真的有东西可接时出现。
// 关掉窗口后回来的人想要的是接着往下走,不是从空白开一个新的 ——
// 把它埋在「Claude Code」下面等于让用户每次都重开一个。
if let resumable {
items.append(RowMenu.Item(
"接着上次(\(RelativeTime.short(from: resumable.endedAt))前结束)"
) { onLaunch(.resumeSession) })
items.append(nil)
}
items += LaunchMode.allCases
.filter { $0 != .resumeSession }
.map { mode in RowMenu.Item(mode.label) { onLaunch(mode) } }
items.append(nil)
items.append(RowMenu.Item(isPinned ? "取消置顶" : "置顶", action: onTogglePin))
RowMenu.present(items)
RowMenu.present(
ProjectMenu.items(
isPinned: isPinned, resumable: resumable, isMissing: isMissing,
onLaunch: onLaunch, onTogglePin: onTogglePin, onRemove: onRemove
)
)
}
private var rowBody: some View {
@@ -1162,7 +1163,11 @@ struct ProjectRowView: View {
.foregroundStyle(.white.opacity(0.5))
.rotationEffect(.degrees(45))
}
if running.count > 0 {
if isMissing {
Text("目录已不存在")
.font(IslandTheme.label(10, .medium))
.foregroundStyle(IslandTheme.waiting)
} else if running.count > 0 {
Text("\(running.count) 个会话")
.font(IslandTheme.label(10, .medium))
.foregroundStyle(status.map(IslandTheme.color(for:)) ?? .white)
+70 -7
View File
@@ -394,6 +394,37 @@ struct ProjectsPane: View {
return store.sessions.filter { $0.cwd == root || $0.cwd.hasPrefix(root + "/") }
}
/// 选一个已有目录登记成项目。
///
/// **只登记,不创建、不改动目录本身。**「新建」是另一个按钮。
private func addExistingDirectory() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = true
panel.prompt = "添加"
panel.message = "选择要加入清单的项目目录(不限于 git 仓库)"
panel.directoryURL = URL(
fileURLWithPath: NSString(string: "~/Documents/code").expandingTildeInPath
)
guard panel.runModal() == .OK else { return }
for url in panel.urls { projects.addExisting(path: url.path) }
}
/// 这一行的菜单内容。▶ 按钮和右键共用。
private func menuItems(for project: Project) -> [RowMenu.Item?] {
ProjectMenu.items(
isPinned: projects.isPinned(project),
resumable: runningCount(project) == 0
? closedSessions.latest(forProject: project.expandedPath)
: nil,
isMissing: projects.isMissing(project),
onLaunch: { onLaunch(project, $0) },
onTogglePin: { projects.togglePin(project) },
onRemove: { projects.remove([project]) }
)
}
/// 一行的会话说明。
///
/// **全部会话都断开时不能只写「N 个会话」** —— 用户已经把窗口关了,
@@ -431,6 +462,25 @@ struct ProjectsPane: View {
Label("扫描", systemImage: "arrow.clockwise")
}
.disabled(projects.isScanning)
// 扫描只认有 .git 的目录 —— monorepo 里的子目录、还没 git init
// 的目录永远扫不进来(实机 40 条里有 7 条是这种)。
// 这是"能加载进去"真正缺的那个入口。
Button {
addExistingDirectory()
} label: {
Label("添加目录", systemImage: "folder.badge.plus")
}
if !projects.missingProjects.isEmpty {
Button(role: .destructive) {
projects.remove(projects.missingProjects)
} label: {
Label(
"清理失效 \(projects.missingProjects.count)",
systemImage: "trash"
)
}
.help("这些项目的目录已经不在了。只从清单里移除,不动磁盘上的任何文件。")
}
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
@@ -469,6 +519,12 @@ struct ProjectsPane: View {
.foregroundStyle(.secondary)
.rotationEffect(.degrees(45))
}
if projects.isMissing(project) {
Label("目录已不存在", systemImage:
"exclamationmark.triangle.fill")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(IslandTheme.waiting)
}
if let summary = Self.sessionSummary(
running: runningCount(project),
detached: detachedCount(project)
@@ -507,6 +563,7 @@ struct ProjectsPane: View {
Text(project.path)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
.strikethrough(projects.isMissing(project))
.lineLimit(1)
.truncationMode(.head)
}
@@ -515,21 +572,27 @@ struct ProjectsPane: View {
GitChip(info: projects.git(for: project))
Menu {
ForEach(LaunchMode.allCases, id: \.self) { mode in
if mode != .resumeSession {
Button(mode.label) { onLaunch(project, mode) }
// 和右键、和岛上那份是同一批菜单项。
// 各写一遍必然漂移,见 ProjectMenu 的说明。
let items = menuItems(for: project)
ForEach(items.indices, id: \.self) { index in
if let item = items[index] {
Button(item.title, action: item.action)
} else {
Divider()
}
}
Divider()
Button(projects.isPinned(project) ? "取消置顶" : "置顶") {
projects.togglePin(project)
}
} label: {
Image(systemName: "play.circle")
}
.menuStyle(.borderlessButton)
.fixedSize()
}
// 右键整行 = 同一个菜单。每次都得瞄准行尾那个小按钮
// 才能选开法,是这一版之前最鸡肋的地方。
.overlay(RightClickCatcher {
RowMenu.present(menuItems(for: project))
})
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import HubProjects
/// 项目行的菜单内容。
///
/// **岛和主窗口共用这一份。** 两边各写一遍的话必然漂移 —— 实际上已经漂过:
/// 岛的菜单里有「接着上次」,主窗口的没有,同一个项目在两个界面上能做的事
/// 不一样,而用户没有任何线索知道为什么。
///
/// 三个入口都走这里:右键、"…" 按钮、主窗口的 ▶ 按钮。
@MainActor
enum ProjectMenu {
/// - Parameter resumable: 上一个被「关窗即结束」收掉、且接得回来的会话。
/// 有它就排第一 —— 关掉窗口后回来的人想接着往下走,
/// 不是从空白开一个新的。
static func items(
isPinned: Bool,
resumable: ClosedSession?,
/// 目录已经不在了。这时候不该给"开项目"的选项 —— tmux 对不存在的
/// `-c` 目录返回 0 并静默回落到家目录,点了只会开出一个开在家目录的
/// claude,然后用户以为是菜单坏了。只留「从列表移除」。
isMissing: Bool = false,
onLaunch: @escaping (LaunchMode) -> Void,
onTogglePin: @escaping () -> Void,
onRemove: (() -> Void)? = nil
) -> [RowMenu.Item?] {
var items: [RowMenu.Item?] = []
// 目录没了就只给「移除」这一条。
//
// **一个都不留是刻意的**:这里能放的每一项(开 Claude、开终端、
// 开 Finder)在目录不存在时全都是"点了没反应"或者"开到家目录",
// 而这次事故的全部内容就是这些静默失败。
// 给一个假的可点项,等于把刚修好的问题又造一遍。
if isMissing {
items.append(RowMenu.Item("⚠︎ 目录已不存在,无法打开", action: {}))
items.append(nil)
if let onRemove {
items.append(RowMenu.Item("从列表移除", action: onRemove))
}
return items
}
if let resumable {
items.append(RowMenu.Item(
"接着上次(\(RelativeTime.short(from: resumable.endedAt))前结束)"
) { onLaunch(.resumeSession) })
items.append(nil)
}
// `.resumeSession` 不进这一段:它要一个具体的 sessionId,
// 而那个 id 只有上面那条「接着上次」知道。菜单里再放一个同名项
// 只会退化成 `claude --resume` 的交互列表。
items += LaunchMode.allCases
.filter { $0 != .resumeSession }
.map { mode in RowMenu.Item(mode.label) { onLaunch(mode) } }
items.append(nil)
items.append(RowMenu.Item(isPinned ? "取消置顶" : "置顶", action: onTogglePin))
if let onRemove {
items.append(RowMenu.Item("从列表移除", action: onRemove))
}
return items
}
}
+65
View File
@@ -1,4 +1,5 @@
import AppKit
import SwiftUI
/// 在鼠标位置弹一个原生 NSMenu。
///
@@ -37,6 +38,70 @@ enum RowMenu {
}
}
/// 右键(以及 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
@@ -0,0 +1,174 @@
import XCTest
@testable import HubProjects
/// 从 projects.yaml 里删掉项目。
///
/// 实机背景:40 条里有 6 条指向已经被删掉的目录(agentx-agent 那一批)。
/// 它们不是无害的脏数据 —— tmux 对不存在的 `-c` 目录**返回 0 并静默回落到
/// 家目录**,`open` 则什么都不做,于是点这些项目表现成
/// 「Finder 没反应」+「Claude 开到 /Users/dev」,看起来像右键菜单坏了。
/// 用户当时的话是"有目录污染的,不能清理"。
///
/// 删除必须是**文本手术**:这份文件是给人手改的,解析完重写会把注释、
/// 别名、描述、分段空行全抹平。`AcceptanceStore` 和 `SessionWatchdog`
/// 都因为"内存状态直接盖盘"弄丢过手改内容,同一个错不能犯第三次。
final class ProjectRemovalTests: XCTestCase {
private let sample = """
# 我的项目清单
scan_dirs:
- ~/Documents/code
projects:
- name: keep-me
path: ~/code/keep-me
aliases: [k, 保留]
description: "要留下的"
# 这条是坏的,目录早没了
- name: dead
path: ~/code/dead
aliases: [d]
description: "该删掉"
- name: also-keep
path: ~/code/also-keep
"""
private func parse(_ text: String) -> [String] {
ProjectYAML.parse(text).map(\.name)
}
// MARK: - 删对了吗
func testRemovesTheTargetedEntry() {
let result = ProjectYAML.removing(names: ["dead"], from: sample)
XCTAssertEqual(parse(result), ["keep-me", "also-keep"])
}
/// **不能留下没主的字段行。**
///
/// 只删 `- name: dead` 那一行的话,下面的 `path:` / `aliases:` 会被解析器
/// 挂到**前一条**项目上 —— 删一条把另一条也弄坏了,而且完全静默。
func testDoesNotLeaveOrphanFieldsBehind() {
let result = ProjectYAML.removing(names: ["dead"], from: sample)
XCTAssertFalse(result.contains("~/code/dead"), "path 行没被一起删掉:\n\(result)")
XCTAssertFalse(result.contains("该删掉"), "description 行没被一起删掉")
let kept = ProjectYAML.parse(result)
XCTAssertEqual(kept.first?.path, "~/code/keep-me", "前一条的 path 被污染了")
XCTAssertEqual(kept.first?.aliases, ["k", "保留"], "前一条的别名被污染了")
}
/// 手写的注释、别名、描述一个都不能丢 —— 这是不用"重写整份"的全部理由。
func testPreservesHandWrittenContent() {
let result = ProjectYAML.removing(names: ["dead"], from: sample)
XCTAssertTrue(result.contains("# 我的项目清单"))
XCTAssertTrue(result.contains("scan_dirs:"))
XCTAssertTrue(result.contains("aliases: [k, 保留]"))
XCTAssertTrue(result.contains("要留下的"))
}
/// 一次删多条("清掉全部失效项目"走的就是这条)。
func testRemovesSeveralAtOnce() {
let result = ProjectYAML.removing(names: ["dead", "also-keep"], from: sample)
XCTAssertEqual(parse(result), ["keep-me"])
}
/// 删最后一条时不能把后面的顶层段落一起吞掉。
func testStopsAtTheNextTopLevelKey() {
let text = """
projects:
- name: dead
path: ~/code/dead
scan_dirs:
- ~/Documents/code
"""
let result = ProjectYAML.removing(names: ["dead"], from: text)
XCTAssertTrue(result.contains("scan_dirs:"), "顶层段落被一起删了:\n\(result)")
XCTAssertTrue(result.contains("~/Documents/code"))
}
/// 名字对不上就一个字都不该动。
func testUnknownNameChangesNothing() {
XCTAssertEqual(ProjectYAML.removing(names: ["nope"], from: sample), sample)
}
func testEmptySetChangesNothing() {
XCTAssertEqual(ProjectYAML.removing(names: [], from: sample), sample)
}
// MARK: - 加回来
/// 扫描只认有 `.git` 的目录,所以 monorepo 子目录、没 git init 的目录
/// 永远扫不进来 —— 实机 40 条里有 7 条是这种,只能手写 yaml。
/// `addExisting` 是那个缺掉的入口。
@MainActor
func testAddExistingRegistersAnyDirectory() throws {
let (store, url) = try makeStore()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let dir = url.deletingLastPathComponent()
.appendingPathComponent("plain-dir", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
XCTAssertTrue(store.addExisting(path: dir.path))
XCTAssertTrue(store.projects.contains { $0.name == "plain-dir" })
}
/// 目录不存在就不该登记 —— 登记进去就是又造一条"点了开到家目录"的污染。
@MainActor
func testAddExistingRejectsMissingDirectory() throws {
let (store, url) = try makeStore()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
XCTAssertFalse(store.addExisting(path: "/definitely/not/here"))
}
/// 加过的不再加第二遍:重名条目会让删除变得没法预测。
@MainActor
func testAddExistingIsIdempotent() throws {
let (store, url) = try makeStore()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let dir = url.deletingLastPathComponent()
.appendingPathComponent("twice", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
XCTAssertTrue(store.addExisting(path: dir.path))
XCTAssertFalse(store.addExisting(path: dir.path))
XCTAssertEqual(store.projects.count { $0.name == "twice" }, 1)
}
/// 目录没了的项目要被标出来。不标的话用户看到的是一个完全正常的行,
/// 点下去却开到家目录 —— 那正是这次事故的样子。
@MainActor
func testMissingDirectoriesAreFlagged() throws {
let (store, url) = try makeStore(
yaml: """
projects:
- name: gone
path: /definitely/not/here
"""
)
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let gone = try XCTUnwrap(store.projects.first)
XCTAssertTrue(store.isMissing(gone))
XCTAssertEqual(store.missingProjects.map(\.name), ["gone"])
}
@MainActor
private func makeStore(yaml: String? = nil) throws -> (ProjectStore, URL) {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("projstore-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let url = root.appendingPathComponent("projects.yaml")
try (yaml ?? "projects:\n").write(to: url, atomically: true, encoding: .utf8)
let store = ProjectStore(yamlURL: url, pinURL: nil)
store.load()
return (store, url)
}
}
@@ -0,0 +1,88 @@
import HubProjects
import XCTest
@testable import HubUI
/// 项目行的菜单内容。
///
/// 用户的原话:「不然每次要点击后面的按钮才能选择有点鸡肋」。修法是把菜单
/// 接到右键上,而右键、"…" 按钮、主窗口的 ▶ 按钮**必须是同一份内容** ——
/// 各写一遍必然漂移,实际上已经漂过一次:岛的菜单里有「接着上次」,
/// 主窗口的没有,同一个项目在两个界面上能做的事不一样,
/// 而用户没有任何线索知道为什么。
@MainActor
final class ProjectMenuTests: XCTestCase {
private func items(
isPinned: Bool = false, resumable: ClosedSession? = nil
) -> [RowMenu.Item?] {
ProjectMenu.items(
isPinned: isPinned, resumable: resumable,
onLaunch: { _ in }, onTogglePin: {}
)
}
private var titles: [String] {
items().compactMap { $0?.title }
}
/// 每一种开法都要在。少一个的话,那个开法就只剩"没有入口"这一种状态。
func testEveryLaunchModeIsPresent() {
let titles = self.titles
for mode in LaunchMode.allCases where mode != .resumeSession {
XCTAssertTrue(titles.contains(mode.label), "菜单里少了「\(mode.label)」")
}
}
/// **`.resumeSession` 不能出现在通用那一段。**
///
/// 它要一个具体的 sessionId,而那个 id 只有「接着上次」那条知道。
/// 放进来只会退化成 `claude --resume` 的交互列表 —— 那个列表里
/// 全是一模一样的项目名,用户得人肉认。
func testGenericSectionExcludesResumeSession() {
XCTAssertFalse(titles.contains(LaunchMode.resumeSession.label))
}
/// 没东西可接时不该出现「接着上次」——一个点了什么都不会发生的菜单项
/// 比没有这一项更糟。
func testNoResumeEntryWhenNothingToResume() {
XCTAssertFalse(titles.contains { $0.hasPrefix("接着上次") })
}
/// 有可接回的会话时,它**排第一**。关掉窗口后回来的人想接着往下走,
/// 埋在「Claude Code」下面等于让他每次都重开一个新的。
func testResumeEntryComesFirst() {
let closed = ClosedSession(
sessionId: "s1", name: "app-a1", cwd: "/x", endedAt: Date()
)
let first = items(resumable: closed).first??.title
XCTAssertNotNil(first)
XCTAssertTrue(
first?.hasPrefix("接着上次") == true,
"第一项应该是「接着上次」,实际是「\(first ?? "nil")」"
)
}
/// 「接着上次」后面要有分隔线,否则它和普通开法糊成一片,
/// 看不出这一条是不一样的东西。
func testResumeEntryIsSeparatedFromTheRest() {
let closed = ClosedSession(
sessionId: "s1", name: "app-a1", cwd: "/x", endedAt: Date()
)
let list = items(resumable: closed)
XCTAssertNil(list[1], "「接着上次」后面应该是分隔线")
}
/// 置顶项跟着当前状态换文案 —— 显示成「置顶」但实际是取消置顶,
/// 用户点一次才知道。
func testPinLabelReflectsCurrentState() {
XCTAssertTrue(items(isPinned: false).compactMap { $0?.title }.contains("置顶"))
XCTAssertTrue(items(isPinned: true).compactMap { $0?.title }.contains("取消置顶"))
}
/// 置顶排在最后,且前面有分隔线 —— 它不是"一种开法"。
func testPinIsLastAndSeparated() {
let list = items()
XCTAssertEqual(list.last??.title, "置顶")
XCTAssertNil(list[list.count - 2], "置顶前面应该是分隔线")
}
}