Files
vibe-foreman/HubKit/Sources/HubProjects/ProjectStore.swift
T
078f6f5e46 feat: 岛上作答交互卡 + 项目置顶/排序 + 建仓与克隆 (#6)
## 岛上能直接作答 Claude 的交互请求

AskUserQuestion / ExitPlanMode 的 tool_input 里没有 command/path,
RiskClassifier 提不出 summary,风险判定为 normal 直接放行 ——
岛上从来不弹任何东西。这是这次要修的核心 bug。

现在按 toolName 在风险判定**之前**分流,岛上弹可作答的卡:

- 选择题:单题单选点选即答;多题表单逐题作答,多选勾完提交,
  最后合成一条回传。答案借 deny 的 reason 送回模型(PreToolUse
  没有「替用户作答」的通道,这是唯一的口子)。
- 计划审批:显式 allow 压不掉 plan 状态机自己的确认框(实测
  2.1.220),所以「批准」= 放行后替用户在终端框里按 1/2,带重试
  等对话框渲染出来;驳回走 hook deny 有效。
- 进入计划模式(EnterPlanMode)同样接管。
- 终端权限框("Do you want to...")走 Notification hook 弹卡,
  允许=注入 1、拒绝=Esc,复用 TerminalReply 的全套护栏。

超时方向和审批卡**相反**:审批超时必须 deny(唯一的安全刹车),
交互卡超时 allow(不输出决策 = 交还终端原生对话框,用户零损失)。
这条镜像关系有专门的测试钉住。

## 项目列表

- 置顶:岛和主窗口菜单里都能置顶,落盘 pinned.json。
- 排序统一成 置顶 > 正在开发 > 最近提交 > 名字。GitInfo 加
  lastCommitAt(38 个仓库合计约 300ms,后台队列)—— 三十多个项目
  按 yaml 顺序排,找一个要翻半屏。
- 岛上无会话的行点击弹启动菜单,不再直接起 claude(误触成本太高)。
  用原生 NSMenu:SwiftUI Menu 的两种整行方案都翻过车(label 被
  压扁 / 透明盖层收不到点击)。

## 建仓与克隆

- scripts/new-project.sh:目录 + git + GitHub 远程 + 防污染
  settings.json + 注册 projects.yaml,一条命令。app、/new-project
  skill、命令行三个入口共用同一份逻辑。
- scripts/clone-project.sh + 克隆表单:列出账号可见的全部仓库
  (含组织仓,之前 gh repo list 只列个人仓),按 owner 分组筛选,
  多账号可切换(gh 只用活跃账号的凭据,不切就拉不了另一个账号的
  私有仓)。列表按账号分开缓存 + 后台静默刷新。
- 防污染 deny 刻意**不禁** ~/.claude/plans/:plan mode 的计划文件
  写在那里,禁了所有 plan 会话都会报 Error writing file(真踩过)。

## 顺带修掉

add-project.sh 最后一行是 `[ ${#ALIASES[@]} -gt 0 ] && echo ...`,
没有别名时测试为假,整个脚本退出码变成 1 —— 明明写进去了,调用方
却报「注册 projects.yaml 失败」。改成显式 exit 0。

Co-authored-by: wuguangan <anjaihd0206@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:19:39 +08:00

282 lines
11 KiB
Swift
Raw 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")
)
}
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()
refreshGit()
return
}
// 一个候选都没解析出项目:如实报告最先找到的那份出了什么问题。
if let (url, outcome) = firstFound {
sourceURL = url
projects = []
loadDiagnostic = outcome.diagnostic ?? "文件里没有任何项目条目"
} else {
sourceURL = nil
projects = []
loadDiagnostic = "找不到 projects.yaml"
}
rebuildPathIndex()
}
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) {
guard 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"
}
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")
}
}