diff --git a/HubKit/NOTES.md b/HubKit/NOTES.md index c470797..7aa8f18 100644 --- a/HubKit/NOTES.md +++ b/HubKit/NOTES.md @@ -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/` diff --git a/HubKit/Sources/HubProbeCLI/main.swift b/HubKit/Sources/HubProbeCLI/main.swift index c8dd966..a54643d 100644 --- a/HubKit/Sources/HubProbeCLI/main.swift +++ b/HubKit/Sources/HubProbeCLI/main.swift @@ -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) } diff --git a/HubKit/Sources/HubProjects/Project.swift b/HubKit/Sources/HubProjects/Project.swift index 440683b..ad46486 100644 --- a/HubKit/Sources/HubProjects/Project.swift +++ b/HubKit/Sources/HubProjects/Project.swift @@ -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, 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 diff --git a/HubKit/Sources/HubProjects/ProjectStore.swift b/HubKit/Sources/HubProjects/ProjectStore.swift index ad937e4..c8080f4 100644 --- a/HubKit/Sources/HubProjects/ProjectStore.swift +++ b/HubKit/Sources/HubProjects/ProjectStore.swift @@ -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 = [] + + public func isMissing(_ project: Project) -> Bool { missingPaths.contains(project.id) } + + /// 重新检查每个项目的目录还在不在。 + /// + /// 跟着 git 轮询走:40 次 stat 是微秒级的,而"目录被删了"这件事 + /// 本来就该在下一次刷新时就看见,不该等重启。 + private func refreshMissing() { + var missing: Set = [] + 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) } diff --git a/HubKit/Sources/HubProjects/TerminalDispatch.swift b/HubKit/Sources/HubProjects/TerminalDispatch.swift index 5148f94..d79dfe1 100644 --- a/HubKit/Sources/HubProjects/TerminalDispatch.swift +++ b/HubKit/Sources/HubProjects/TerminalDispatch.swift @@ -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)。 /// /// 脚本自己是幂等的、也自己兜底不阻断,所以这里失败了什么都不用做 —— diff --git a/HubKit/Sources/HubUI/IslandRootView.swift b/HubKit/Sources/HubUI/IslandRootView.swift index 377bb3c..5d6cc38 100644 --- a/HubKit/Sources/HubUI/IslandRootView.swift +++ b/HubKit/Sources/HubUI/IslandRootView.swift @@ -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) diff --git a/HubKit/Sources/HubUI/MainWindow.swift b/HubKit/Sources/HubUI/MainWindow.swift index 5b3094c..5812355 100644 --- a/HubKit/Sources/HubUI/MainWindow.swift +++ b/HubKit/Sources/HubUI/MainWindow.swift @@ -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)) + }) } } } diff --git a/HubKit/Sources/HubUI/ProjectMenu.swift b/HubKit/Sources/HubUI/ProjectMenu.swift new file mode 100644 index 0000000..49430c6 --- /dev/null +++ b/HubKit/Sources/HubUI/ProjectMenu.swift @@ -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 + } +} diff --git a/HubKit/Sources/HubUI/RowMenu.swift b/HubKit/Sources/HubUI/RowMenu.swift index f852bbb..44aa657 100644 --- a/HubKit/Sources/HubUI/RowMenu.swift +++ b/HubKit/Sources/HubUI/RowMenu.swift @@ -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 diff --git a/HubKit/Tests/HubProjectsTests/ProjectRemovalTests.swift b/HubKit/Tests/HubProjectsTests/ProjectRemovalTests.swift new file mode 100644 index 0000000..3698c08 --- /dev/null +++ b/HubKit/Tests/HubProjectsTests/ProjectRemovalTests.swift @@ -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) + } +} diff --git a/HubKit/Tests/HubUITests/ProjectMenuTests.swift b/HubKit/Tests/HubUITests/ProjectMenuTests.swift new file mode 100644 index 0000000..1ca7b71 --- /dev/null +++ b/HubKit/Tests/HubUITests/ProjectMenuTests.swift @@ -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], "置顶前面应该是分隔线") + } +}