Files
vibe-foreman/HubKit/Sources/HubUI/AcceptancePane.swift
T
wuguanganandClaude Opus 5 a5ba3f7573 feat: Hub 自己跑验收命令 —— 实际功能证明
阶段 4 的教训直接催生了这一步:diff 复核把 `case ran(command:exitCode:)`
这个**枚举声明**当成了功能已实现,而没有任何代码产生它。读代码判断不了
"功能是否真的能用",只有跑一遍能。

执行者必须是 Hub 而不是 Claude —— 让它跑完再转述,就又回到了「说说而已」,
转述那一步正是失真发生的地方。产出 Evidence.ran(isProof = true)。

三道闸,全部有测试,元字符那道做过变异验证:

1. 白名单:只放行构建/测试类。刻意排除 npx / pnpm dlx(会下载执行任意包,
   放行它等于白名单形同虚设)、npm install(跑 postinstall 等于执行任意
   代码)、git 的写操作。bash 限定在项目的 scripts/ 下。
2. 元字符:16 个字符逐个测。**这道检查必须在白名单之前** ——
   变异验证时把它挪到后面,`swift test | tee /tmp/x` 立刻因为前缀匹配上
   `swift test` 而被放行。配合 Shell.run 走 execve 数组参数(不是 sh -c)。
3. 总开关默认 off + 逐条授权。授权按命令原文精确匹配,改一个字要重新授权;
   而且手工往 authorized 里塞危险命令也绕不过前两道(单独一条测试守)。

顺带修了注入正文太长:真实项目上拦了 12 条带完整验收条件,糊了满屏。
要点一多就没人看,反而比不拦更容易被忽略。上限 6 条,存疑的排前面,
省略了多少明确说出来。

Shell.run 加 currentDirectory —— 验收命令必须在项目目录里跑,
否则 swift test 找不到 Package.swift。

388 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:37:45 +08:00

630 lines
24 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 AppKit
import HubCore
import HubProbe
import HubProjects
import SwiftUI
/// 验收清单页。
///
/// ## 为什么不做二级分栏
///
/// 直觉写法是左边项目列表、右边清单。但主窗口的 sidebar 已经占掉 190–260pt,
/// 再切一列,清单那一栏就只剩三四百点宽 —— 要点文本本来就长,证据里还有文件路径,
/// 那个宽度下每一行都要折成三行,等于什么都看不清。
///
/// 所以改成:顶部一个项目选择器 + 一行汇报条,下面整宽的分组列表。
///
/// ## 汇报级
///
/// 列表每行只给三样:状态、要点文本(截断)、证据徽章。**点开单条才展开细节。**
/// 逐条证据和 diff 全文走「导出报告」——窗口这个宽度摊不开,硬摊只会让人不看。
struct AcceptancePane: View {
let acceptance: AcceptanceStore
let projects: ProjectStore
let store: SessionStore
let settings: VerifierSettings
let verifier: AcceptanceVerifier
@Binding var selection: String?
/// 正在跑验证的那一条。同时只允许跑一条 —— 并发跑 `swift build`
/// 会互相抢构建目录锁,结果全是假失败。
@State private var running: String?
@State private var expanded: Set<String> = []
@State private var draft = ""
@State private var pickerOpen = false
@State private var query = ""
@State private var lane: AcceptanceLedger.Lane = .pending
@State private var diff: DiffRequest?
/// 「看改动」弹的那张 sheet 的内容。
struct DiffRequest: Identifiable {
let id = UUID()
let path: String
let itemText: String
var patch: String? // nil = 还在读
}
@FocusState private var searchFocused: Bool
/// 选择器里要列的项目:**所有已登记项目**,有清单的排前面。
///
/// 早先只列「已经有清单的项目」,结果一条清单都没有时选择器整个不渲染 ——
/// 用户对着一个不知道往哪写的输入框。清单是按项目分的,那"当前是哪个项目"
/// 就必须**任何时候都看得见**,而不是等有了数据才出现。
private var pickerPaths: [String] {
var seen = Set<String>()
var result: [String] = []
for path in acceptance.projectPaths where seen.insert(path).inserted {
result.append(path)
}
for path in projects.projects.map(\.expandedPath) where seen.insert(path).inserted {
result.append(path)
}
// currentPath 可能两边都不在(比如会话跑在没登记的目录里),也得能选中。
if let path = defaultPath, seen.insert(path).inserted { result.insert(path, at: 0) }
return result
}
/// 当前在看哪个项目。
///
/// **`selection` 优先,哪怕它还没有清单。** 用户从会话行点「清单」进来时,
/// 那个项目通常一条要点都还没有;如果这时回退到"第一个有清单的项目",
/// 用户敲进去的第一条要点就会落到**别的项目**上 —— 清单挂错项目,这功能就废了。
private var currentPath: String? { selection ?? defaultPath }
/// 没选过时的默认项目:**当前有会话在跑的那个**。
/// 你正在干的活通常就是你要验收的活,让你少点一次。
private var defaultPath: String? {
if let session = store.sessions.first {
return AcceptanceStore.projectPath(forCWD: session.cwd, projects: projects)
}
return acceptance.projectPaths.first ?? projects.projects.first?.expandedPath
}
private var ledger: AcceptanceLedger? {
currentPath.map { acceptance.ledger(for: $0) }
}
var body: some View {
VStack(spacing: 0) {
PaneHeader(
title: "验收",
subtitle: "独立于 Claude 自述的需求清单。基线来自你的原话和你批准过的计划。"
)
if !pickerPaths.isEmpty { controls }
if ledger?.items.isEmpty ?? true {
ContentUnavailableView(
currentPath.map { "\(label(for: $0)) 还没有清单" } ?? "还没有项目",
systemImage: "checklist",
description: Text("在下面手动加一条,或等 Claude Hub 从你的需求里自动拆出来")
)
.frame(maxHeight: .infinity)
} else {
lanes
list
}
composer
}
.sheet(item: $diff) { request in
diffSheet(request)
}
}
// MARK: - 点击回溯
@ViewBuilder
private func diffSheet(_ request: DiffRequest) -> some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 3) {
Text(request.path)
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.lineLimit(1).truncationMode(.head)
Text(request.itemText)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(14)
Divider()
if let patch = request.patch {
ScrollView([.vertical, .horizontal]) {
Text(patch.isEmpty ? "这个文件从基线到现在没有改动。" : patch)
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
}
} else {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
}
Divider()
HStack {
Spacer()
Button("关闭") { diff = nil }.keyboardShortcut(.defaultAction)
}
.padding(10)
}
.frame(width: 760, height: 520)
}
/// 读某个文件从基线到现在的 diff。
///
/// 在后台读:`git diff` 要 fork 一个进程,大仓库上能到几百毫秒,
/// 放在主线程会让整个窗口卡一下。
private func openDiff(path: String, item: AcceptanceItem) {
guard let cwd = currentPath else { return }
diff = DiffRequest(path: path, itemText: item.text, patch: nil)
let since = item.baselineCommit
Task.detached(priority: .userInitiated) {
let patch = GitDiff.patch(cwd, since: since, paths: [path], limit: 200_000)
await MainActor.run {
// 用户可能已经关掉或点了别的文件 —— 只更新还在等的那一个。
guard diff?.path == path else { return }
diff?.patch = patch
}
}
}
// MARK: - 顶部:项目选择 + 汇报条
@ViewBuilder
private var controls: some View {
HStack(spacing: 10) {
// 永远显示。清单是按项目分的,"现在是哪个项目"必须任何时候都看得见。
//
// 不用 Picker:项目有几十个,原生下拉是一条几十项的长列表,
// 找一个项目要肉眼扫一遍。换成带搜索的弹出层。
Button {
query = ""
pickerOpen = true
} label: {
HStack(spacing: 5) {
Text(currentPath.map { label(for: $0) } ?? "选择项目")
.font(.system(size: 13, weight: .medium))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
}
}
.buttonStyle(.bordered)
.popover(isPresented: $pickerOpen, arrowEdge: .bottom) {
projectSearch
}
if let ledger {
Text(ledger.headline)
.font(.system(size: 12, weight: .medium)).monospacedDigit()
.foregroundStyle(ledger.disputedCount > 0 ? IslandTheme.danger : .secondary)
}
Spacer()
Button("导出报告") { export() }
.buttonStyle(.borderless)
.font(.system(size: 12, weight: .medium))
.disabled(ledger?.items.isEmpty ?? true)
}
.padding(.horizontal, 20)
.padding(.bottom, 10)
}
// MARK: - 项目搜索
@ViewBuilder
private var projectSearch: some View {
VStack(spacing: 0) {
TextField("搜索项目…", text: $query)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12))
.focused($searchFocused)
.onSubmit {
// 回车直接选中唯一/第一个结果 —— 打两个字母就走,不用抬手去点。
if let first = filteredPaths.first { choose(first) }
}
.padding(8)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(filteredPaths, id: \.self) { path in
projectRow(path)
}
if filteredPaths.isEmpty {
Text("没有匹配的项目")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.padding(12)
}
}
}
.frame(maxHeight: 280)
}
.frame(width: 280)
// 弹出即聚焦搜索框:打开选择器的意图就是"我要找一个",
// 还要先点一下输入框等于白给一次点击。
.onAppear { searchFocused = true }
}
@ViewBuilder
private func projectRow(_ path: String) -> some View {
let ledger = acceptance.ledger(for: path)
Button {
choose(path)
} label: {
HStack(spacing: 6) {
Text(label(for: path))
.font(.system(size: 12, weight: path == currentPath ? .semibold : .regular))
.lineLimit(1)
Spacer(minLength: 4)
// 需要关注的条数直接标出来,扫一眼就知道该去哪个项目。
if ledger.disputedCount > 0 {
Text("\(ledger.disputedCount)")
.font(.system(size: 10, weight: .bold)).monospacedDigit()
.foregroundStyle(IslandTheme.danger)
}
if ledger.openCount > 0 {
Text("\(ledger.openCount)")
.font(.system(size: 10)).monospacedDigit()
.foregroundStyle(IslandTheme.waiting)
}
if path == currentPath {
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
}
}
.contentShape(.rect)
.padding(.horizontal, 10)
.padding(.vertical, 5)
}
.buttonStyle(.plain)
}
private func choose(_ path: String) {
selection = path
pickerOpen = false
query = ""
}
/// 搜索结果。先做子串匹配,没命中再退到子序列匹配。
///
/// 子序列那一档是给缩写用的:项目名普遍是 `demo-ios` / `demo-android`
/// 这种长复合词,打 `nios` 就能定位比打全名快得多。但它**只在子串无结果时**
/// 才启用 —— 子序列匹配太宽松,直接用会让精确输入也返回一堆噪音。
private var filteredPaths: [String] {
let needle = query.trimmingCharacters(in: .whitespaces).lowercased()
guard !needle.isEmpty else { return pickerPaths }
let direct = pickerPaths.filter {
label(for: $0).lowercased().contains(needle) || $0.lowercased().contains(needle)
}
guard direct.isEmpty else { return direct }
return pickerPaths.filter {
AcceptancePane.isSubsequence(needle, of: label(for: $0).lowercased())
}
}
/// `needle` 的每个字符是否按顺序出现在 `haystack` 里。
static func isSubsequence(_ needle: String, of haystack: String) -> Bool {
var iterator = haystack.makeIterator()
for character in needle {
var matched = false
while let next = iterator.next() {
if next == character { matched = true; break }
}
guard matched else { return false }
}
return true
}
// MARK: - 列表
/// 分段切换。
///
/// 带计数是刻意的:不带的话你得先点进去才知道那一段是不是空的,
/// 而「疑似未做」这一段通常就是空的 —— 让人白点一次。
@ViewBuilder
private var lanes: some View {
Picker("", selection: $lane) {
ForEach(AcceptanceLedger.Lane.allCases) { item in
Text("\(item.rawValue) \(ledger?.count(in: item) ?? 0)").tag(item)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
@ViewBuilder
private var list: some View {
let items = ledger?.items(in: lane) ?? []
if items.isEmpty {
ContentUnavailableView(
"「\(lane.rawValue)」是空的",
systemImage: lane == .disputed ? "checkmark.circle" : "tray",
description: Text(
lane == .disputed ? "没有自报做完却在代码里找不到的项 —— 这是好事" : "换一段看看"
)
)
.frame(maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(spacing: 6) {
ForEach(items) { row($0) }
}
.padding(.horizontal, 20)
.padding(.vertical, 8)
}
}
}
@ViewBuilder
private func row(_ item: AcceptanceItem) -> some View {
Card {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Circle()
.fill(Self.color(for: item.status))
.frame(width: 7, height: 7)
Text(item.text)
.font(.system(size: 13))
.lineLimit(expanded.contains(item.id) ? nil : 1)
Spacer(minLength: 6)
Text(item.origin.label)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.padding(.horizontal, 5).padding(.vertical, 1)
.background(.quaternary, in: .capsule)
proofBadge(item)
}
.contentShape(.rect)
.onTapGesture { toggle(item.id) }
if expanded.contains(item.id) {
detail(item)
}
}
}
}
/// 证据徽章。**三档必须能一眼分开** —— 「仅自述」和「跑过」看起来一样的话,
/// 这个功能就退化成了转述 Claude 的话。
@ViewBuilder
private func proofBadge(_ item: AcceptanceItem) -> some View {
if item.evidence.isEmpty {
Text("无证据")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
} else if item.hasProof {
let ran = item.evidence.contains { if case .ran = $0 { return true } else { return false } }
Label(ran ? "跑过" : "有改动", systemImage: ran ? "gearshape.fill" : "plusminus")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(IslandTheme.shell)
} else {
Label("仅自述", systemImage: "quote.bubble")
.font(.system(size: 10))
.foregroundStyle(IslandTheme.waiting)
}
}
@ViewBuilder
private func detail(_ item: AcceptanceItem) -> some View {
VStack(alignment: .leading, spacing: 6) {
if let acceptance = item.acceptance {
Text("验收条件:\(acceptance)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
verifyControls(item, acceptance)
}
if let note = item.note {
Text(note)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
ForEach(Array(item.evidence.enumerated()), id: \.offset) { _, evidence in
HStack(spacing: 5) {
Text(evidence.isProof ? "✅" : "○")
.font(.system(size: 10))
Text(evidence.shortLabel)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
// 点文件看真实 diff —— 「点击回溯看看实际更新的」。
// 只有 .diff 有得看:自述里没有文件,跑命令的输出在报告里。
if case .diff(let path, _, _) = evidence {
Button("看改动") { openDiff(path: path, item: item) }
.buttonStyle(.borderless)
.font(.system(size: 10, weight: .medium))
}
}
}
HStack(spacing: 12) {
if item.status != .accepted {
Button("接受") { setStatus(.accepted, item) }
}
if item.status != .dropped {
Button("划掉") { setStatus(.dropped, item) }
}
if item.isSettledByUser {
Button("撤销") { setStatus(.open, item) }
}
Spacer()
Button("删除") { remove(item) }
.foregroundStyle(IslandTheme.danger)
}
.buttonStyle(.borderless)
.font(.system(size: 11, weight: .medium))
.padding(.top, 2)
}
.padding(.leading, 15)
}
// MARK: - 实际功能证明(Hub 自己跑)
/// 验收条件下面那一行控件。
///
/// 三道闸的状态在这里全都要**说出来**,不能只显示一个能点的按钮:
/// 用户得知道这条为什么能跑 / 为什么不能跑。一个默默不工作的按钮
/// 比没有按钮更糟。
@ViewBuilder
private func verifyControls(_ item: AcceptanceItem, _ acceptance: String) -> some View {
let check = AcceptanceVerifier.classify(acceptance)
HStack(spacing: 8) {
switch check {
case .notACommand:
Text("这条只能人工确认")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
case .rejected(let reason):
Label(reason, systemImage: "hand.raised.fill")
.font(.system(size: 10))
.foregroundStyle(IslandTheme.danger)
case .allowed(let argv):
let command = argv.joined(separator: " ")
if !settings.enabled {
Text("总开关关着 —— 去设置里打开「自动跑验收命令」")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
} else if acceptanceStoreAuthorized(command) {
Button(running == item.id ? "跑着…" : "现在验证") {
runVerification(item, command: command)
}
.buttonStyle(.borderless)
.font(.system(size: 11, weight: .medium))
.disabled(running != nil)
Button("撤销授权") { revoke(command) }
.buttonStyle(.borderless)
.font(.system(size: 10))
.foregroundStyle(.secondary)
} else {
// 命令原文原样显示 —— 授权的前提是看得见到底要跑什么。
Button("允许执行 `\(command)`") { authorize(command) }
.buttonStyle(.borderless)
.font(.system(size: 11, weight: .medium))
}
}
Spacer()
}
}
private func acceptanceStoreAuthorized(_ command: String) -> Bool {
guard let path = currentPath else { return false }
return acceptance.isAuthorized(command, in: path)
}
private func authorize(_ command: String) {
guard let path = currentPath else { return }
acceptance.authorize(command, in: path)
}
private func revoke(_ command: String) {
guard let path = currentPath else { return }
acceptance.revoke(command, in: path)
}
private func runVerification(_ item: AcceptanceItem, command: String) {
guard let path = currentPath else { return }
running = item.id
let verifier = self.verifier
let authorized = Set(acceptance.ledger(for: path).authorizedCommands)
Task {
let outcome = await verifier.verify(
acceptance: item.acceptance, cwd: path, authorized: authorized
)
running = nil
guard case .ran(let evidence) = outcome else { return }
acceptance.recordVerification(evidence, forID: item.id, in: path)
// 展开着才看得到结果。
expanded.insert(item.id)
}
}
// MARK: - 手动加一条
@ViewBuilder
private var composer: some View {
HStack(spacing: 8) {
// 占位符里带上项目名 —— 输入框和顶部选择器隔着整个列表,
// 光标在这儿时看不见上面选的是谁。
TextField(
currentPath.map { "给「\(label(for: $0))」加一条要点…" } ?? "手动加一条要点…",
text: $draft
)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12))
.onSubmit { addDraft() }
Button("加入") { addDraft() }
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
// MARK: - 动作
private func toggle(_ id: String) {
if expanded.contains(id) { expanded.remove(id) } else { expanded.insert(id) }
}
private func setStatus(_ status: AcceptanceItem.Status, _ item: AcceptanceItem) {
guard let path = currentPath else { return }
acceptance.setStatus(status, forID: item.id, in: path)
}
private func remove(_ item: AcceptanceItem) {
guard let path = currentPath else { return }
acceptance.remove(id: item.id, from: path)
expanded.remove(item.id)
}
private func addDraft() {
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
guard let path = currentPath else { return }
acceptance.add(AcceptanceItem(text: text, origin: .manual), to: path)
selection = path
draft = ""
}
private func label(for path: String) -> String {
projects.projectName(forPath: path) ?? (path as NSString).lastPathComponent
}
/// 导出报告。报告即归档 —— 不做第二套渲染。
private func export() {
guard let path = currentPath else { return }
let name = label(for: path)
let panel = NSSavePanel()
panel.nameFieldStringValue = "验收报告-\(name).md"
panel.allowedContentTypes = [.plainText]
guard panel.runModal() == .OK, let url = panel.url else { return }
let markdown = acceptance.markdownReport(for: path, projectName: name)
try? markdown.data(using: .utf8)?.write(to: url, options: .atomic)
}
static func color(for status: AcceptanceItem.Status) -> Color {
switch status {
case .disputed: return IslandTheme.danger
case .open: return IslandTheme.waiting
case .claimed: return IslandTheme.busy
case .confirmed, .accepted: return IslandTheme.shell
case .dropped: return .secondary
}
}
}