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

269 lines
10 KiB
Swift

import Foundation
/// projects.yaml 里的一个项目。
public struct Project: Identifiable, Hashable, Sendable {
public let name: String
public let path: String
public let aliases: [String]
public let description: String?
public let tags: [String]
public var id: String { path }
public init(
name: String, path: String, aliases: [String] = [],
description: String? = nil, tags: [String] = []
) {
self.name = name
self.path = path
self.aliases = aliases
self.description = description
self.tags = tags
}
/// 展开 `~/`。yaml 里普遍写成 `~/Documents/code/xxx`。
public var expandedPath: String {
guard path.hasPrefix("~") else { return path }
return NSString(string: path).expandingTildeInPath
}
}
/// git 状态。
/// 一条提交记录。
public struct GitCommit: Hashable, Sendable, Identifiable {
public let hash: String
public let subject: String
/// git 自己给的相对时间("3 minutes ago")—— 不自己算,省一次时间戳解析,
/// 而且 git 的措辞已经本地化过。
public let relativeDate: String
public let author: String
public var id: String { hash }
public init(hash: String, subject: String, relativeDate: String, author: String) {
self.hash = hash
self.subject = subject
self.relativeDate = relativeDate
self.author = author
}
}
/// 一个仓库工作区的快照:状态 + 最近提交。
///
/// 按**会话的 cwd** 采集而不是按 projects.yaml 里的项目 —— 会话可能开在
/// 任意目录、任意 worktree,甚至根本不在已登记的项目里。同一个项目多开时,
/// 这份快照是区分它们的主要依据。
public struct WorkspaceGit: Hashable, Sendable {
public let root: String
public let info: GitInfo
public let recent: [GitCommit]
public init(root: String, info: GitInfo, recent: [GitCommit]) {
self.root = root
self.info = info
self.recent = recent
}
}
public struct GitInfo: Hashable, Sendable {
public let branch: String?
public let changeCount: Int
public let ahead: Int
public let behind: Int
public let hasUpstream: Bool
/// 最后一次提交的时间。项目列表按它排"最近在做的"。
public let lastCommitAt: Date?
public var isClean: Bool { changeCount == 0 }
public init(
branch: String?, changeCount: Int, ahead: Int, behind: Int, hasUpstream: Bool,
lastCommitAt: Date? = nil
) {
self.branch = branch
self.changeCount = changeCount
self.ahead = ahead
self.behind = behind
self.hasUpstream = hasUpstream
self.lastCommitAt = lastCommitAt
}
}
/// 极简 YAML 解析。
///
/// 只认 projects.yaml 这一种结构,不引 Yams。理由是常驻 app 的长期维护成本:
/// 多一个依赖就多一次跟着 Swift 版本升级的负担,而这里需要的表达力极其有限。
///
/// 沿用现有 Rust 版 `parse_yaml` 的宽松行解析契约(trim 后判前缀),
/// 这样两边对同一个文件的理解一致,迁移期间不会打架。
public enum ProjectYAML {
/// 解析结果 + 诊断。
///
/// 存在的理由是一次真实事故:文件里明明有 117 条项目,解析器却返回空数组,
/// UI 老老实实显示"0 个项目",**没有任何地方提示出了问题**。
/// 排查它花的时间远超写这个类型的成本 —— 解析失败必须能被看见。
public struct Outcome: Sendable {
public let projects: [Project]
/// 文件里出现过多少个 `- name:`(不管有没有被成功解析)。
public let entryMarkers: Int
/// 文件里有条目但一条都没解析出来 —— 格式坏了。
public var looksBroken: Bool { projects.isEmpty && entryMarkers > 0 }
public var diagnostic: String? {
guard looksBroken else { return nil }
return "文件里有 \(entryMarkers) 条项目,但一条都没能解析出来 —— 检查 `projects:` 这一行的格式"
}
}
public static func parse(_ text: String) -> [Project] {
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
var name: String?
var path: String?
var description: String?
var aliases: [String] = []
var tags: [String] = []
var inProjectsSection = false
func flush() {
if let name, let path {
projects.append(
Project(
name: name, path: path, aliases: aliases,
description: description, tags: tags
)
)
}
name = nil
path = nil
description = nil
aliases = []
tags = []
}
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
let line = String(rawLine)
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
if trimmed.hasPrefix("- name:") || trimmed.hasPrefix("-name:") {
entryMarkers += 1
}
// 顶层键。scan_dirs 之类的段落要跳过,只处理 projects。
//
// 判定必须按**键名**而不是整行字面量。踩过的坑:原来写的是
// `trimmed == "projects:"`,于是 `projects: []` 完全不匹配 ——
// 旧版留下的空数组标记后面被追加了 117 条项目,全部被静默忽略。
// 行内值(`[]`、`~`、`null`)只是"暂时没有内联条目",
// 不代表下面缩进的列表不算数。
if !line.hasPrefix(" ") && !line.hasPrefix("\t"),
let key = topLevelKey(of: trimmed) {
flush()
inProjectsSection = key == "projects"
continue
}
guard inProjectsSection else { continue }
if trimmed.hasPrefix("- ") {
flush()
let rest = String(trimmed.dropFirst(2))
if let value = value(of: "name", in: rest) { name = value }
continue
}
if let value = value(of: "name", in: trimmed) { name = value }
else if let value = value(of: "path", in: trimmed) { path = value }
else if let value = value(of: "description", in: trimmed) { description = value }
else if let value = value(of: "aliases", in: trimmed) { aliases = list(value) }
else if let value = value(of: "tags", in: trimmed) { tags = list(value) }
}
flush()
return Outcome(projects: projects, entryMarkers: entryMarkers)
}
/// 顶层键名。`projects:` / `projects: []` / `scan_dirs:` 都能取到键名;
/// 列表项(`- xxx`)和纯文本行返回 nil。
private static func topLevelKey(of trimmed: String) -> String? {
guard !trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else {
return nil
}
let key = String(trimmed[trimmed.startIndex..<colon])
guard !key.isEmpty, !key.contains(" ") else { return nil }
return key
}
private static func value(of key: String, in line: String) -> String? {
let prefix = key + ":"
guard line.hasPrefix(prefix) else { return nil }
return unquote(String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces))
}
private static func list(_ raw: String) -> [String] {
var body = raw
if body.hasPrefix("["), body.hasSuffix("]") {
body = String(body.dropFirst().dropLast())
}
return body
.split(separator: ",")
.map { unquote($0.trimmingCharacters(in: .whitespaces)) }
.filter { !$0.isEmpty }
}
private static func unquote(_ raw: String) -> String {
var value = raw
for quote in ["\"", "'"] where value.hasPrefix(quote) && value.hasSuffix(quote) && value.count >= 2 {
value = String(value.dropFirst().dropLast())
}
return value
}
}