## 岛上能直接作答 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>
150 lines
5.3 KiB
Swift
150 lines
5.3 KiB
Swift
import XCTest
|
||
@testable import HubProjects
|
||
|
||
@MainActor
|
||
final class ProjectPinTests: XCTestCase {
|
||
|
||
// 每个 case 都用临时 pinURL(或 nil)建 store —— 默认路径指向用户真实的
|
||
// ~/Library/Application Support/claude-hub/pinned.json,
|
||
// 测试跑进去既会互相污染,也会往用户数据里写垃圾。
|
||
|
||
private func project(_ name: String) -> Project {
|
||
Project(name: name, path: "~/Documents/code/\(name)")
|
||
}
|
||
|
||
private func tempPinURL() -> URL {
|
||
FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("pinned-\(UUID().uuidString).json")
|
||
}
|
||
|
||
// MARK: - 状态翻转与持久化
|
||
|
||
func testTogglePinFlipsState() {
|
||
let store = ProjectStore(pinURL: nil)
|
||
let target = project("alpha")
|
||
|
||
XCTAssertFalse(store.isPinned(target))
|
||
store.togglePin(target)
|
||
XCTAssertTrue(store.isPinned(target))
|
||
store.togglePin(target)
|
||
XCTAssertFalse(store.isPinned(target))
|
||
}
|
||
|
||
/// 置顶必须活过重启 —— 只存内存的话每次开 app 都要重新置顶一遍,
|
||
/// 这个功能等于不存在。
|
||
func testPinsSurviveRestart() {
|
||
let url = tempPinURL()
|
||
defer { try? FileManager.default.removeItem(at: url) }
|
||
|
||
let first = ProjectStore(pinURL: url)
|
||
first.togglePin(project("alpha"))
|
||
first.togglePin(project("beta"))
|
||
first.togglePin(project("beta")) // beta 取消,只剩 alpha
|
||
|
||
let reopened = ProjectStore(pinURL: url)
|
||
XCTAssertTrue(reopened.isPinned(project("alpha")))
|
||
XCTAssertFalse(reopened.isPinned(project("beta")))
|
||
}
|
||
|
||
func testNilPinURLDoesNotPersist() {
|
||
let store = ProjectStore(pinURL: nil)
|
||
store.togglePin(project("alpha"))
|
||
// 没有落盘位置:新实例什么都读不到(也不该崩)。
|
||
XCTAssertFalse(ProjectStore(pinURL: nil).isPinned(project("alpha")))
|
||
}
|
||
|
||
// MARK: - 排序:置顶 > 正在开发 > 最近提交 > 名字
|
||
|
||
func testRankPutsPinnedFirstThenRunningThenName() {
|
||
let plain = project("bbb")
|
||
let running = project("ccc")
|
||
let pinnedIdle = project("zzz")
|
||
|
||
let ordered = ProjectOrdering.rank(
|
||
[plain, running, pinnedIdle],
|
||
pinned: [pinnedIdle.id],
|
||
runningCount: { $0.id == running.id ? 2 : 0 },
|
||
lastCommitAt: { _ in nil }
|
||
)
|
||
|
||
XCTAssertEqual(
|
||
ordered.map(\.name), ["zzz", "ccc", "bbb"],
|
||
"置顶压过运行中,运行中压过名字序"
|
||
)
|
||
}
|
||
|
||
/// 最近提交的排前面 —— 这条是「38 个项目里找不到在做的那个」的解药。
|
||
func testRankOrdersIdleProjectsByMostRecentCommit() {
|
||
let old = project("aaa-old")
|
||
let fresh = project("zzz-fresh")
|
||
let middle = project("mmm-mid")
|
||
let dates: [String: Date] = [
|
||
old.id: Date(timeIntervalSince1970: 1_000),
|
||
middle.id: Date(timeIntervalSince1970: 5_000),
|
||
fresh.id: Date(timeIntervalSince1970: 9_000),
|
||
]
|
||
|
||
let ordered = ProjectOrdering.rank(
|
||
[old, fresh, middle], pinned: [],
|
||
runningCount: { _ in 0 },
|
||
lastCommitAt: { dates[$0.id] }
|
||
)
|
||
|
||
XCTAssertEqual(ordered.map(\.name), ["zzz-fresh", "mmm-mid", "aaa-old"])
|
||
}
|
||
|
||
/// 没有提交记录(新仓 / 读不到 git)的排最后,同档按名字 ——
|
||
/// 列表每 6 秒刷新一次,排序必须稳定,不然行会来回跳。
|
||
func testRankPutsUnknownCommitDatesLastAndStable() {
|
||
let known = project("zzz-known")
|
||
let unknownB = project("bbb-unknown")
|
||
let unknownA = project("aaa-unknown")
|
||
|
||
let ordered = ProjectOrdering.rank(
|
||
[unknownB, known, unknownA], pinned: [],
|
||
runningCount: { _ in 0 },
|
||
lastCommitAt: { $0.id == known.id ? Date(timeIntervalSince1970: 1) : nil }
|
||
)
|
||
|
||
XCTAssertEqual(ordered.map(\.name), ["zzz-known", "aaa-unknown", "bbb-unknown"])
|
||
}
|
||
|
||
/// 正在跑的会话压过"最近提交" —— 此刻在做的事优先级最高。
|
||
func testRunningBeatsRecentCommit() {
|
||
let running = project("aaa-running")
|
||
let recentlyCommitted = project("zzz-recent")
|
||
|
||
let ordered = ProjectOrdering.rank(
|
||
[recentlyCommitted, running], pinned: [],
|
||
runningCount: { $0.id == running.id ? 1 : 0 },
|
||
lastCommitAt: { $0.id == recentlyCommitted.id ? Date() : nil }
|
||
)
|
||
|
||
XCTAssertEqual(ordered.first?.name, "aaa-running")
|
||
}
|
||
|
||
// MARK: - 只提前置顶项(保留能力)
|
||
|
||
/// yaml 里的手工分组顺序是用户维护的,置顶只能把置顶项提前,
|
||
/// 不能顺手把其余项目重排。
|
||
func testPinnedFirstIsStablePartition() {
|
||
let list = [project("d"), project("b"), project("c"), project("a")]
|
||
|
||
let ordered = ProjectOrdering.pinnedFirst(
|
||
list, pinned: [project("c").id, project("b").id]
|
||
)
|
||
|
||
XCTAssertEqual(
|
||
ordered.map(\.name), ["b", "c", "d", "a"],
|
||
"置顶组和未置顶组内部都必须保持传入顺序"
|
||
)
|
||
}
|
||
|
||
func testPinnedFirstWithNoPinsReturnsInputOrder() {
|
||
let list = [project("d"), project("b")]
|
||
XCTAssertEqual(
|
||
ProjectOrdering.pinnedFirst(list, pinned: []).map(\.name), ["d", "b"]
|
||
)
|
||
}
|
||
}
|