## 岛上能直接作答 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>
306 lines
12 KiB
Swift
306 lines
12 KiB
Swift
import AppKit
|
||
import HubProbe
|
||
import HubProjects
|
||
import SwiftUI
|
||
|
||
/// 主窗口的「克隆项目」表单:从绑定的 gh 账号列出仓库点选,或手动贴地址。
|
||
/// 真正干活的是 scripts/clone-project.sh(gh 登录态直接可拉私有仓)。
|
||
struct CloneProjectSheet: View {
|
||
let projects: ProjectStore
|
||
/// 主窗口持有的共享实例:带磁盘缓存,打开即显示上次列表,后台静默刷新。
|
||
let git: GitAccountStore
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var command = CloneCommand()
|
||
@State private var repoFilter = ""
|
||
/// nil = 不按 owner 过滤。
|
||
@State private var ownerFilter: String?
|
||
@State private var phase: Phase = .editing
|
||
@State private var output = ""
|
||
|
||
private enum Phase: Equatable {
|
||
case editing
|
||
case running
|
||
case finished(success: Bool)
|
||
}
|
||
|
||
private var filteredRepos: [RemoteRepo] {
|
||
var list = git.repos
|
||
if let ownerFilter {
|
||
list = list.filter { $0.nameWithOwner.hasPrefix(ownerFilter + "/") }
|
||
}
|
||
guard !repoFilter.isEmpty else { return list }
|
||
let needle = repoFilter.lowercased()
|
||
return list.filter { $0.nameWithOwner.lowercased().contains(needle) }
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text("克隆项目")
|
||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||
Spacer()
|
||
accountBadge
|
||
}
|
||
|
||
if phase == .editing {
|
||
repoPicker
|
||
}
|
||
|
||
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 8) {
|
||
GridRow {
|
||
Text("仓库")
|
||
TextField("owner/repo 或 git 地址", text: $command.repo)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
GridRow {
|
||
Text("克隆到")
|
||
HStack(spacing: 6) {
|
||
TextField("~/Documents/code", text: $command.parentDir)
|
||
.textFieldStyle(.roundedBorder)
|
||
Button("选…") { pickParentDir() }
|
||
}
|
||
}
|
||
GridRow {
|
||
Text("目录名")
|
||
TextField("留空 = \(command.derivedName.isEmpty ? "仓库名" : command.derivedName)",
|
||
text: $command.nameOverride)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
}
|
||
.disabled(phase == .running)
|
||
|
||
if phase != .editing {
|
||
outputView
|
||
}
|
||
|
||
Spacer(minLength: 0)
|
||
buttons
|
||
}
|
||
.padding(20)
|
||
.frame(width: 560, height: 500)
|
||
.onAppear { git.refresh() }
|
||
}
|
||
|
||
/// 账号切换器。多账号时这是关键入口 —— gh 只用**活跃账号**的凭据,
|
||
/// 不切过去就看不见、也拉不了另一个账号的私有仓。
|
||
private var accountBadge: some View {
|
||
Group {
|
||
if git.accounts.isEmpty {
|
||
if let diagnostic = git.diagnostic {
|
||
Label(diagnostic, systemImage: "exclamationmark.triangle")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.orange)
|
||
}
|
||
} else if git.accounts.count == 1, let only = git.accounts.first {
|
||
Label("\(only.login) · \(only.host)",
|
||
systemImage: "person.crop.circle.badge.checkmark")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
Menu {
|
||
ForEach(git.accounts) { account in
|
||
Button {
|
||
ownerFilter = nil
|
||
git.switchTo(account)
|
||
} label: {
|
||
if account.active {
|
||
Label("\(account.login) · \(account.host)", systemImage: "checkmark")
|
||
} else {
|
||
Text("\(account.login) · \(account.host)")
|
||
}
|
||
}
|
||
}
|
||
} label: {
|
||
HStack(spacing: 4) {
|
||
if git.isSwitching {
|
||
ProgressView().controlSize(.small)
|
||
} else {
|
||
Image(systemName: "person.crop.circle.badge.checkmark")
|
||
}
|
||
Text(git.activeAccount.map { "\($0.login) · \($0.host)" } ?? "选择账号")
|
||
Text("(\(git.accounts.count))")
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
.font(.system(size: 11))
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.fixedSize()
|
||
.disabled(git.isSwitching)
|
||
.help("切换 gh 活跃账号;克隆用的就是这个账号的凭据")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// owner 筛选标签(个人 + 各组织)。一个账号能看到十几个组织时,
|
||
/// 光靠搜索框要记得住组织名才行。
|
||
@ViewBuilder
|
||
private var ownerChips: some View {
|
||
let owners = git.owners
|
||
if owners.count > 1 {
|
||
ScrollView(.horizontal) {
|
||
HStack(spacing: 6) {
|
||
chip(title: "全部 \(git.repos.count)", selected: ownerFilter == nil) {
|
||
ownerFilter = nil
|
||
}
|
||
ForEach(owners, id: \.name) { owner in
|
||
chip(title: "\(owner.name) \(owner.count)",
|
||
selected: ownerFilter == owner.name) {
|
||
ownerFilter = ownerFilter == owner.name ? nil : owner.name
|
||
}
|
||
}
|
||
}
|
||
.padding(.vertical, 1)
|
||
}
|
||
.scrollIndicators(.never)
|
||
}
|
||
}
|
||
|
||
private func chip(title: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
Text(title)
|
||
.font(.system(size: 11))
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 3)
|
||
.background(
|
||
selected ? Color.accentColor.opacity(0.18) : Color.black.opacity(0.05),
|
||
in: .capsule
|
||
)
|
||
.foregroundStyle(selected ? Color.accentColor : .secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
/// 账号可及的仓库列表(含组织仓),点一行填进仓库输入框。
|
||
private var repoPicker: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 8) {
|
||
TextField("搜索仓库(含组织)…", text: $repoFilter)
|
||
.textFieldStyle(.roundedBorder)
|
||
if git.isRefreshing {
|
||
ProgressView().controlSize(.small)
|
||
} else if let refreshed = git.lastRefreshed {
|
||
Text(refreshed, style: .relative)
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
ownerChips
|
||
ScrollView {
|
||
LazyVStack(spacing: 2) {
|
||
ForEach(filteredRepos) { repo in
|
||
Button {
|
||
command.repo = repo.nameWithOwner
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: repo.isPrivate ? "lock.fill" : "globe")
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(.secondary)
|
||
Text(repo.nameWithOwner)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
Spacer(minLength: 0)
|
||
if command.repo == repo.nameWithOwner {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 10, weight: .bold))
|
||
}
|
||
}
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
command.repo == repo.nameWithOwner
|
||
? Color.accentColor.opacity(0.15) : .clear,
|
||
in: .rect(cornerRadius: 5)
|
||
)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
// 只在完全没数据时占位;有缓存时刷新是无感的。
|
||
if git.repos.isEmpty {
|
||
Text(git.isRefreshing
|
||
? "加载仓库列表…"
|
||
: (git.diagnostic ?? "没有拉到仓库列表,可直接在下面贴地址"))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
.padding(6)
|
||
}
|
||
}
|
||
}
|
||
.frame(height: 170)
|
||
.background(Color.black.opacity(0.04), in: .rect(cornerRadius: 6))
|
||
}
|
||
}
|
||
|
||
private var outputView: some View {
|
||
ScrollView {
|
||
Text(output.isEmpty ? "执行中…" : output)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(8)
|
||
.frame(maxHeight: 150)
|
||
.background(Color.black.opacity(0.05), in: .rect(cornerRadius: 6))
|
||
}
|
||
|
||
private var buttons: some View {
|
||
HStack {
|
||
if case .finished(let success) = phase, success {
|
||
Label("已克隆并加入项目列表", systemImage: "checkmark.circle.fill")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.green)
|
||
}
|
||
Spacer()
|
||
if case .finished = phase {
|
||
Button("完成") { dismiss() }
|
||
.keyboardShortcut(.defaultAction)
|
||
} else {
|
||
Button("取消") { dismiss() }
|
||
.disabled(phase == .running)
|
||
Button(phase == .running ? "克隆中…" : "克隆") { clone() }
|
||
.keyboardShortcut(.defaultAction)
|
||
.disabled(command.validationError != nil || phase == .running)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func pickParentDir() {
|
||
let panel = NSOpenPanel()
|
||
panel.canChooseDirectories = true
|
||
panel.canChooseFiles = false
|
||
panel.directoryURL = URL(
|
||
fileURLWithPath: NSString(string: command.parentDir).expandingTildeInPath
|
||
)
|
||
if panel.runModal() == .OK, let url = panel.url {
|
||
command.parentDir = url.path
|
||
}
|
||
}
|
||
|
||
private func clone() {
|
||
guard command.validationError == nil else { return }
|
||
phase = .running
|
||
output = ""
|
||
let cmd = command
|
||
let script = CloneCommand.scriptURL(near: projects.sourceURL).path
|
||
Task.detached(priority: .userInitiated) {
|
||
let result = Shell.run(
|
||
"/bin/bash",
|
||
cmd.arguments(scriptPath: script),
|
||
timeout: 600,
|
||
environment: ["PATH": NewProjectCommand.guiPATH]
|
||
)
|
||
let text = NewProjectSheet.stripANSI(
|
||
[result.stdout, result.stderr]
|
||
.filter { !$0.isEmpty }
|
||
.joined(separator: "\n")
|
||
)
|
||
await MainActor.run {
|
||
output = text
|
||
phase = .finished(success: result.succeeded)
|
||
if result.succeeded { projects.load() }
|
||
}
|
||
}
|
||
}
|
||
}
|