## 根因
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>
269 lines
10 KiB
Swift
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
|
|
}
|
|
}
|