Files
vibe-foreman/HubKit/Sources/HubProjects/Project.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

229 lines
8.3 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
}
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
}
}