Files
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

306 lines
12 KiB
Swift
Raw Permalink 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 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() }
}
}
}
}