Files
wuguanganandClaude Opus 5 3af92f27cf chore: 仓库改名 vibe-foreman,代码认新旧两个目录名
仓库 gulugulu-cn/claude-hub → gulugulu-cn/vibe-foreman。
GitHub 自动重定向旧地址,6 个 Release 全部保留。

代码里按 ~/Documents/code/claude-hub 找脚本和 projects.yaml 的地方,
改成新旧两个目录名都试 —— 老用户的工作区还叫旧名,新 clone 的是新名,
少认一个的表现是"从界面开项目时项目配置没被补齐",而那是静默的。

**Application Support 下的 claude-hub 目录不改名。** 那是用户数据落盘的
地方(验收清单、盯梢配置、已结束会话名册、置顶、审批日志),
改了等于把现有配置全孤立掉。

530 个测试全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:05:03 +08:00

371 lines
14 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 Foundation
import HubProbe
/// 项目列表 + git 状态。
@Observable
@MainActor
public final class ProjectStore {
public private(set) var projects: [Project] = []
public private(set) var gitInfo: [String: GitInfo] = [:] // key = expandedPath
public private(set) var isScanning = false
/// 置顶的项目。key = `Project.id`(yaml 里的 path 原文,含 `~`),
/// 这样 yaml 重载、项目暂时消失再回来,置顶都不丢。
public private(set) var pinned: Set<String> = []
/// 实际读到的那个文件。
///
/// **必须对外可见。** 这次的坑就是"不知道 app 在读哪份 projects.yaml" ——
/// 脚本和 CLAUDE.md 维护的是仓库里那份,app 却在读 Application Support 里
/// 另一份旧的,两边永远对不上,而 UI 上只显示一个孤零零的"0 个项目"。
public private(set) var sourceURL: URL?
/// 解析失败的说明。UI 要显示出来,不能让文件坏了还静默显示 0。
public private(set) var loadDiagnostic: String?
private let explicitURL: URL?
private var gitTimer: Timer?
/// cwd → 项目名 的反查表。hook 事件只带 cwd,要靠它换成用户认识的项目名。
private var pathIndex: [String: String] = [:]
/// 置顶状态的落盘位置。和 approval-log.json 一样放 Application Support。
public static var defaultPinURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub/pinned.json")
}
/// nil = 不落盘。必须可注入,理由同 ApprovalCoordinator.logURL:
/// 路径写死会让测试直接污染用户的真实数据。
private let pinURL: URL?
public init(yamlURL: URL? = nil, pinURL: URL? = ProjectStore.defaultPinURL) {
self.explicitURL = yamlURL
self.pinURL = pinURL
loadPins()
}
// MARK: - 置顶
public func isPinned(_ project: Project) -> Bool {
pinned.contains(project.id)
}
public func togglePin(_ project: Project) {
if pinned.contains(project.id) {
pinned.remove(project.id)
} else {
pinned.insert(project.id)
}
persistPins()
}
private func loadPins() {
guard let pinURL,
let data = try? Data(contentsOf: pinURL),
let decoded = try? JSONDecoder().decode([String].self, from: data)
else { return }
pinned = Set(decoded)
}
/// 不清理已消失项目的 pin:项目可能只是暂时从 yaml 移走,脏数据无害且量极小。
private func persistPins() {
guard let pinURL else { return }
try? FileManager.default.createDirectory(
at: pinURL.deletingLastPathComponent(), withIntermediateDirectories: true
)
// sorted 保证写盘内容稳定,diff 友好。
guard let data = try? JSONEncoder().encode(pinned.sorted()) else { return }
try? data.write(to: pinURL, options: .atomic)
}
// MARK: - 加载
/// 按优先级找 projects.yaml。
///
/// **仓库那份优先**:`add-project.sh`、`scan-projects.sh` 和 CLAUDE.md
/// 全都以它为准,app 读别处就等于两套数据。Application Support 那份
/// 只作为"没有克隆仓库"时的兜底。
public var candidateURLs: [URL] {
if let explicitURL { return [explicitURL] }
let home = FileManager.default.homeDirectoryForCurrentUser
var urls: [URL] = []
if let dir = ProcessInfo.processInfo.environment["CLAUDE_HUB_DIR"], !dir.isEmpty {
urls.append(
URL(fileURLWithPath: NSString(string: dir).expandingTildeInPath)
.appendingPathComponent("projects.yaml")
)
}
// 仓库改过名,新旧两个目录名都找。Application Support 那份**不改名** ——
// 那是用户数据落盘的地方,改了等于把已有的配置全孤立掉。
urls.append(home.appendingPathComponent("Documents/code/vibe-foreman/projects.yaml"))
urls.append(home.appendingPathComponent("Documents/code/claude-hub/projects.yaml"))
urls.append(
home.appendingPathComponent("Library/Application Support/claude-hub/projects.yaml")
)
return urls
}
public func load() {
loadDiagnostic = nil
// 逐个候选试。**空文件和解析不出东西的文件都要继续往下找** ——
// 只判断"文件存不存在"的话,一份坏掉的文件会把好的那份永远挡住,
// 这正是这次 0 个项目的成因。
var firstFound: (URL, ProjectYAML.Outcome)?
for url in candidateURLs {
guard let text = try? String(contentsOf: url, encoding: .utf8) else { continue }
let outcome = ProjectYAML.analyze(text)
if firstFound == nil { firstFound = (url, outcome) }
guard !outcome.projects.isEmpty else { continue }
sourceURL = url
projects = outcome.projects
rebuildPathIndex()
refreshMissing()
refreshGit()
return
}
// 一个候选都没解析出项目:如实报告最先找到的那份出了什么问题。
if let (url, outcome) = firstFound {
sourceURL = url
projects = []
loadDiagnostic = outcome.diagnostic ?? "文件里没有任何项目条目"
} else {
sourceURL = nil
projects = []
loadDiagnostic = "找不到 projects.yaml"
}
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 {
pathIndex[normalize(project.expandedPath)] = project.name
}
}
/// 从任意路径反查项目名。
///
/// 会话的 cwd 常常是项目的**子目录**(用户 cd 进去再起的 claude),
/// 所以要逐级向上找最长匹配,不能只做等值比较。
public func projectName(forPath path: String) -> String? {
var current = normalize(path)
while !current.isEmpty, current != "/" {
if let name = pathIndex[current] { return name }
current = (current as NSString).deletingLastPathComponent
}
return nil
}
private func normalize(_ path: String) -> String {
var value = NSString(string: path).expandingTildeInPath
while value.count > 1, value.hasSuffix("/") { value.removeLast() }
return value
}
// MARK: - git
public func startGitPolling() {
refreshGit()
gitTimer?.invalidate()
// git 状态变化远没有会话状态那么频繁,6 秒足够。
gitTimer = Timer.scheduledTimer(withTimeInterval: 6, repeats: true) { [weak self] _ in
Task { @MainActor in self?.refreshGit() }
}
}
public func stopGitPolling() {
gitTimer?.invalidate()
gitTimer = nil
}
public func refreshGit() {
let paths = projects.map(\.expandedPath)
guard !paths.isEmpty else { return }
// 21 个仓库串行大约 120ms。放后台队列,别让 UI 掉帧。
Task.detached(priority: .utility) {
var collected: [String: GitInfo] = [:]
for path in paths {
if let info = GitStatus.collect(at: path) {
collected[path] = info
}
}
await MainActor.run { [collected] in
self.gitInfo = collected
}
}
}
public func git(for project: Project) -> GitInfo? {
gitInfo[project.expandedPath]
}
/// 按任意路径找 git 状态。
///
/// 会话的 cwd 常常是项目的子目录,所以和 `projectName(forPath:)` 一样
/// 逐级向上找最长匹配 —— 直接拿 cwd 当 key 去查会大面积落空。
public func git(forPath path: String) -> GitInfo? {
var current = normalize(path)
while !current.isEmpty, current != "/" {
if let info = gitInfo[current] { return info }
current = (current as NSString).deletingLastPathComponent
}
return nil
}
// MARK: - 扫描
/// 扫描目录树,把新发现的 git 仓库追加进 yaml。
public func scan(root: String = "~/Documents/code") {
guard !isScanning else { return }
let target = sourceURL ?? candidateURLs.first
guard let target else { return }
isScanning = true
let existing = Set(projects.map { self.normalize($0.expandedPath) })
Task.detached(priority: .userInitiated) {
let found = GitStatus.scanRepositories(under: root)
let fresh = found.filter { !existing.contains($0) }
if !fresh.isEmpty {
Self.append(paths: fresh, to: target)
}
await MainActor.run {
self.isScanning = false
self.load()
}
}
}
/// 把新发现的项目追加进 yaml。
///
/// 追加而不是重写整份:用户手写的别名、描述、注释都要保住。
///
/// 但**追加前必须先规范化 `projects:` 那一行**。这次的事故就是这么来的:
/// 文件里是 `projects: []`(旧版写的空数组),追加逻辑直接 seek 到文件尾
/// 写条目,产出的 YAML 变成"空数组标记后面跟着 117 条内容" ——
/// 解析器看到 `[]` 就认定项目段是空的,全部忽略。
nonisolated static func append(paths: [String], to url: URL) {
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 entry in entries {
text += " - name: \(entry.name)\n path: \(entry.path)\n"
}
try? text.write(to: url, atomically: true, encoding: .utf8)
}
/// 把 `projects: []` / `projects: ~` / `projects: null` 还原成 `projects:`,
/// 让后面缩进的列表条目重新生效。其余内容一字不动。
nonisolated static func normalizeProjectsKey(in text: String) -> String {
let emptyMarkers: Set<String> = ["[]", "~", "null", "{}"]
var lines = text.components(separatedBy: "\n")
for (index, line) in lines.enumerated() {
guard !line.hasPrefix(" "), !line.hasPrefix("\t") else { continue }
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("projects:") else { continue }
let value = String(trimmed.dropFirst("projects:".count))
.trimmingCharacters(in: .whitespaces)
if emptyMarkers.contains(value) {
lines[index] = "projects:"
}
break
}
return lines.joined(separator: "\n")
}
}