## 密钥泄漏闸 把活交给 agent 之后,泄密的形态变了。以前是人手滑,现在是**每一步都很合理的 一串操作**:git add . 顺手把 .env 提了、把真实 token 内联进配置文件当"示例"、 为了确认配置 cat 一下密钥(值就进了 transcript,0644、永久、每轮重发给 API)。 **没有一条能靠「这条命令危不危险」判断出来。** 它们都不危险。唯一可靠的信号是 这次调用的内容里有没有出现真实的密钥值。 所以加了一道闸,默认开:参数里出现库里的真实值 → 拒;git add/commit 会把 .env 一类文件或含真实值的文件带进仓库 → 拒。拒绝理由里写明正确做法 (用 $KEY、先加 .gitignore),否则模型只会换个写法再试一次。 **它跑在 hubctl 本地,不依赖 app 在不在跑。** 仓库其它拦截都是「app 没开就放行」 (一个监控工具不该让你的命令跑不起来),这一条反过来 —— 密钥漏出去不可逆。 几条守住的边界,判宽了比判窄了危险:值短于 8 字符不参与匹配(abc/true/test 会命中一切);.example/.template/.sample 结尾放行(模板文件本来就该提交, 误拦一次用户就把整个功能关掉了);只认 EnvRenderer 写出来的格式,不做通用 dotenv 解析;不是 git add/commit 就一个子进程都不起。 拒了之后事件照发给 app(不等应答),进审批日志 —— 一道看不见的闸, 用户第一次被拦时只会觉得 Claude 抽风。开关在密钥页底部, 落成 ~/.vibe-foreman/guard.json,且**只在关掉时才写**: 默认开,「文件不存在 = 开着」,这样没打开过密钥页的人也受保护。 明确写了它挡不住什么:python -c 读、软链、base64 转一道、库外手写的值。 它是一道减少手滑的闸,不是沙箱。 ## 截图 主窗口现在也能自动截了:HUB_WINDOW_SECTION 钉住打开哪一页, 配 DemoFixtures.seed 造的假密钥和假账号(刻意编得一眼假 —— 一个像真的 token 混进去,读者会去试)。 两个坑:find-window 按面积取第一个会截到岛(它比主窗口先出现一瞬), 改成按宽度 ≥900 筛;.accessory 的进程从后台 shell 起来抢不到焦点, 而非活跃窗口里的系统控件会被去饱和 —— 截出来开关全是灰的, 正好把要展示的东西说反了,所以截图模式下改用 .regular。 顺带:进密钥页自动选中第一组。有组却什么都不选,右边一大片空白, 那是"还没建组"的样子而不是"有四组等你挑"的样子。 646 个测试全过(原 625)。真 hubctl 二进制端到端验过四种情况: 写入真值被拒、约定的 source 用法放行、git add . 带 .env 被拒、普通命令放行。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1229 lines
55 KiB
Swift
1229 lines
55 KiB
Swift
import AppKit
|
||
import HubCore
|
||
import HubIPC
|
||
import HubProbe
|
||
import HubProjects
|
||
import SwiftUI
|
||
|
||
/// 主窗口的分区。
|
||
public enum DashboardSection: String, CaseIterable, Identifiable, Sendable {
|
||
case sessions = "会话"
|
||
case acceptance = "验收"
|
||
case projects = "项目"
|
||
// 两个密钥相关的分区**刻意分开两行**,不合成一个「凭据」页:
|
||
// 一个的值会写成明文文件给 AI 用,一个绝不给任何自动化看到。
|
||
// 放进同一个页面只会让人以为它们是一回事,然后把线上库的密码填错地方。
|
||
case secrets = "共用密钥"
|
||
case credentials = "账号密码"
|
||
case usage = "用量"
|
||
case approvals = "审批日志"
|
||
case settings = "设置"
|
||
|
||
public var id: String { rawValue }
|
||
|
||
/// 给脚本用的英文短名 —— rawValue 是中文,写进 shell 脚本会很难看,
|
||
/// 而且 `HUB_ISLAND_STATE` 那批已有的形态名也全是英文。
|
||
static func fromShortName(_ name: String?) -> DashboardSection? {
|
||
switch name {
|
||
case "sessions": return .sessions
|
||
case "acceptance": return .acceptance
|
||
case "projects": return .projects
|
||
case "secrets": return .secrets
|
||
case "credentials": return .credentials
|
||
case "usage": return .usage
|
||
case "approvals": return .approvals
|
||
case "settings": return .settings
|
||
default: return nil
|
||
}
|
||
}
|
||
|
||
var symbol: String {
|
||
switch self {
|
||
case .sessions: return "bolt.horizontal.circle"
|
||
case .acceptance: return "checklist"
|
||
case .projects: return "folder"
|
||
case .secrets: return "key.fill"
|
||
case .credentials: return "lock.fill"
|
||
case .usage: return "chart.bar"
|
||
case .approvals: return "checkmark.shield"
|
||
case .settings: return "gearshape"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 主窗口。
|
||
///
|
||
/// 分工:**岛 = 一瞥**(零点击获取状态);**主窗口 = 深挖**(用量趋势、
|
||
/// 项目管理、审批日志)。两者共享同一个 `SessionStore` 和同一套原子组件
|
||
/// (`PixelSprite` / `StatusDot` / `GitChip`),这是设计一致性的技术保障 ——
|
||
/// 维护两套组件的话它们必然会漂移。
|
||
///
|
||
/// 视觉上和岛刻意相反:岛强制暗色(因为要贴合恒为黑的刘海),
|
||
/// 主窗口完全跟随系统。岛是"设备的一部分",窗口是"一个 app"。
|
||
public struct MainWindowView: View {
|
||
@Bindable var store: SessionStore
|
||
@Bindable var projects: ProjectStore
|
||
@Bindable var approvals: ApprovalCoordinator
|
||
@Bindable var placement: IslandPlacementStore
|
||
@Bindable var acceptance: AcceptanceStore
|
||
@Bindable var verifierSettings: VerifierSettings
|
||
let verifier: AcceptanceVerifier
|
||
@Bindable var watchdog: SessionWatchdog
|
||
@Bindable var closedSessions: ClosedSessionStore
|
||
@Bindable var reaper: SessionReaper
|
||
@Bindable var secrets: SharedSecretStore
|
||
@Bindable var credentials: CredentialStore
|
||
let channels: HookChannelMonitor
|
||
let onJump: (String) -> Void
|
||
let onLaunch: (Project, LaunchMode) -> Void
|
||
|
||
/// 截图脚本用 `HUB_WINDOW_SECTION` 钉死打开哪一页 ——
|
||
/// 没有它就只能去控制鼠标点侧栏,那在 CI 和别人机器上都不可靠。
|
||
@State private var section: DashboardSection = DashboardSection(
|
||
rawValue: ProcessInfo.processInfo.environment["HUB_WINDOW_SECTION"] ?? ""
|
||
) ?? DashboardSection.fromShortName(
|
||
ProcessInfo.processInfo.environment["HUB_WINDOW_SECTION"]
|
||
) ?? .sessions
|
||
@State private var search = ""
|
||
/// 当前在「验收」页看的是哪个项目。会话行的「清单」按钮会写它。
|
||
@State private var ledgerPath: String?
|
||
/// Git 账号/仓库列表。挂在窗口这一层:克隆表单每次打开复用同一份数据
|
||
/// (带磁盘缓存),只做后台静默刷新,不再让用户每次干等加载。
|
||
@State private var git = GitAccountStore()
|
||
|
||
public init(
|
||
store: SessionStore,
|
||
projects: ProjectStore,
|
||
approvals: ApprovalCoordinator,
|
||
placement: IslandPlacementStore,
|
||
acceptance: AcceptanceStore,
|
||
verifierSettings: VerifierSettings,
|
||
verifier: AcceptanceVerifier,
|
||
watchdog: SessionWatchdog,
|
||
closedSessions: ClosedSessionStore,
|
||
reaper: SessionReaper,
|
||
secrets: SharedSecretStore,
|
||
credentials: CredentialStore,
|
||
channels: HookChannelMonitor,
|
||
onJump: @escaping (String) -> Void,
|
||
onLaunch: @escaping (Project, LaunchMode) -> Void
|
||
) {
|
||
self.store = store
|
||
self.projects = projects
|
||
self.approvals = approvals
|
||
self.placement = placement
|
||
self.acceptance = acceptance
|
||
self.verifierSettings = verifierSettings
|
||
self.verifier = verifier
|
||
self.watchdog = watchdog
|
||
self.closedSessions = closedSessions
|
||
self.reaper = reaper
|
||
self.secrets = secrets
|
||
self.credentials = credentials
|
||
self.channels = channels
|
||
self.onJump = onJump
|
||
self.onLaunch = onLaunch
|
||
}
|
||
|
||
public var body: some View {
|
||
NavigationSplitView {
|
||
List(DashboardSection.allCases, selection: $section) { item in
|
||
Label(item.rawValue, systemImage: item.symbol)
|
||
.tag(item)
|
||
}
|
||
.navigationSplitViewColumnWidth(min: 190, ideal: 210, max: 260)
|
||
} detail: {
|
||
detail
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
}
|
||
// 单一强调色,和岛里 busy 的蓝一致,形成跨形态的一致性。
|
||
.tint(IslandTheme.busy)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var detail: some View {
|
||
switch section {
|
||
case .sessions:
|
||
SessionsPane(
|
||
store: store, projects: projects, onJump: onJump,
|
||
onOpenLedger: { session in
|
||
ledgerPath = AcceptanceStore.projectPath(
|
||
forCWD: session.cwd, projects: projects
|
||
)
|
||
section = .acceptance
|
||
}
|
||
)
|
||
case .acceptance:
|
||
AcceptancePane(
|
||
acceptance: acceptance, projects: projects, store: store,
|
||
settings: verifierSettings, verifier: verifier,
|
||
watchdog: watchdog, selection: $ledgerPath
|
||
)
|
||
case .projects:
|
||
ProjectsPane(
|
||
projects: projects, store: store, git: git,
|
||
closedSessions: closedSessions, secrets: secrets, onLaunch: onLaunch
|
||
)
|
||
case .secrets:
|
||
SecretsPane(secrets: secrets, projects: projects)
|
||
case .credentials:
|
||
CredentialsPane(credentials: credentials, projects: projects)
|
||
case .usage:
|
||
UsagePane()
|
||
case .approvals:
|
||
ApprovalLogPane(approvals: approvals)
|
||
case .settings:
|
||
SettingsPane(
|
||
approvals: approvals, projects: projects, git: git,
|
||
placement: placement, channels: channels, verifierSettings: verifierSettings,
|
||
reaper: reaper
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 通用零件
|
||
|
||
/// 页面大标题。28pt rounded 是 iOS 26 的大标题语言。
|
||
struct PaneHeader: View {
|
||
let title: String
|
||
let subtitle: String?
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(title)
|
||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||
if let subtitle {
|
||
Text(subtitle)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 20)
|
||
.padding(.top, 20)
|
||
.padding(.bottom, 12)
|
||
}
|
||
}
|
||
|
||
/// git 状态芯片。
|
||
struct GitChip: View {
|
||
let info: GitInfo?
|
||
|
||
var body: some View {
|
||
guard let info else { return AnyView(EmptyView()) }
|
||
return AnyView(
|
||
HStack(spacing: 6) {
|
||
if let branch = info.branch {
|
||
Label(branch, systemImage: "arrow.triangle.branch")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
if info.changeCount > 0 {
|
||
Text("+\(info.changeCount)")
|
||
.font(.system(size: 11, weight: .medium)).monospacedDigit()
|
||
.foregroundStyle(IslandTheme.waiting)
|
||
}
|
||
if info.ahead > 0 {
|
||
Text("↑\(info.ahead)")
|
||
.font(.system(size: 11)).monospacedDigit()
|
||
.foregroundStyle(IslandTheme.busy)
|
||
}
|
||
if info.behind > 0 {
|
||
Text("↓\(info.behind)")
|
||
.font(.system(size: 11)).monospacedDigit()
|
||
.foregroundStyle(IslandTheme.busy)
|
||
}
|
||
if !info.hasUpstream {
|
||
Text("无远端")
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 项目行最左边那颗状态点。
|
||
///
|
||
/// 三态,不是两态:没会话(灰实心)、有会话且终端连着(蓝实心)、
|
||
/// 有会话但终端全关了(蓝空心)。第三态以前和第二态长得一模一样 ——
|
||
/// 用户关掉终端后仍看到亮着的蓝点,认定界面在骗人。它没骗人,
|
||
/// 进程确实还在跑,但**「在跑」和「你还找得到它」是两件事**,
|
||
/// 一颗点表达不了两件事。
|
||
struct SessionDot: View {
|
||
let running: Int
|
||
let detached: Int
|
||
|
||
private var allDetached: Bool { running > 0 && detached == running }
|
||
|
||
var body: some View {
|
||
Group {
|
||
if running == 0 {
|
||
Circle().fill(Color.secondary.opacity(0.3))
|
||
} else if allDetached {
|
||
Circle().strokeBorder(IslandTheme.busy.opacity(0.65), lineWidth: 1.6)
|
||
} else {
|
||
Circle().fill(IslandTheme.busy)
|
||
}
|
||
}
|
||
.frame(width: 8, height: 8)
|
||
.help(SessionDot.tooltip(running: running, detached: detached))
|
||
}
|
||
|
||
static func tooltip(running: Int, detached: Int) -> String {
|
||
guard running > 0 else { return "没有会话在跑" }
|
||
guard detached > 0 else { return "\(running) 个会话在跑" }
|
||
if detached == running {
|
||
return "\(running) 个会话还在后台跑,但终端已经关了。tmux 里还留着,重新打开即可接上。"
|
||
}
|
||
return "\(running) 个会话在跑,其中 \(detached) 个的终端已经关了(进程仍在后台)。"
|
||
}
|
||
}
|
||
|
||
/// 内容区卡片。**实心,不用玻璃** —— 大面积玻璃在长时间阅读区会疲劳,
|
||
/// 而且每张卡片都要采样一次背景,9 张就是 9 次。玻璃留给 sidebar 和 toolbar
|
||
/// (那是系统自己给的)。
|
||
struct Card<Content: View>: View {
|
||
@ViewBuilder let content: Content
|
||
|
||
var body: some View {
|
||
content
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
.background.secondary,
|
||
in: .rect(cornerRadius: 14, style: .continuous)
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - 会话
|
||
|
||
struct SessionsPane: View {
|
||
let store: SessionStore
|
||
let projects: ProjectStore
|
||
let onJump: (String) -> Void
|
||
let onOpenLedger: (AgentSession) -> Void
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
PaneHeader(
|
||
title: "会话",
|
||
subtitle: "\(store.sessions.count) 个运行中 · \(store.waitingCount) 个等待你"
|
||
)
|
||
ScrollView {
|
||
LazyVStack(spacing: 8) {
|
||
ForEach(store.sessions) { session in
|
||
Card {
|
||
HStack(spacing: 12) {
|
||
SpriteScreen(cornerRadius: 8) {
|
||
PixelSprite(session: session, pixelSize: 2, gridSize: 16)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 6) {
|
||
StatusDot(status: session.status, size: 7)
|
||
Text(session.name ?? session.sessionId)
|
||
.font(.system(size: 14, weight: .semibold,
|
||
design: .rounded))
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
if session.kind == .bg {
|
||
Text("后台")
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(.secondary)
|
||
.padding(.horizontal, 5)
|
||
.padding(.vertical, 1)
|
||
.background(.quaternary, in: .capsule)
|
||
}
|
||
}
|
||
Text(projectLabel(for: session))
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.secondary)
|
||
if let waitingFor = session.waitingFor {
|
||
Text(waitingFor)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(IslandTheme.waiting)
|
||
}
|
||
}
|
||
|
||
Spacer(minLength: 8)
|
||
|
||
GitChip(info: gitInfo(for: session))
|
||
|
||
Button("清单") { onOpenLedger(session) }
|
||
.buttonStyle(.borderless)
|
||
.font(.system(size: 12, weight: .medium))
|
||
|
||
Button("跳转") { onJump(session.sessionId) }
|
||
.buttonStyle(.borderless)
|
||
.font(.system(size: 12, weight: .medium))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 20)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func projectLabel(for session: AgentSession) -> String {
|
||
projects.projectName(forPath: session.cwd) ?? session.fallbackProjectName
|
||
}
|
||
|
||
private func gitInfo(for session: AgentSession) -> GitInfo? {
|
||
guard let name = projects.projectName(forPath: session.cwd),
|
||
let project = projects.projects.first(where: { $0.name == name })
|
||
else { return nil }
|
||
return projects.git(for: project)
|
||
}
|
||
}
|
||
|
||
// MARK: - 项目
|
||
|
||
struct ProjectsPane: View {
|
||
let projects: ProjectStore
|
||
let store: SessionStore
|
||
let git: GitAccountStore
|
||
let closedSessions: ClosedSessionStore
|
||
let secrets: SharedSecretStore
|
||
let onLaunch: (Project, LaunchMode) -> Void
|
||
|
||
@State private var search = ""
|
||
@State private var showNewProject = false
|
||
@State private var showClone = false
|
||
|
||
private var filtered: [Project] {
|
||
let base: [Project]
|
||
if search.isEmpty {
|
||
base = projects.projects
|
||
} else {
|
||
let needle = search.lowercased()
|
||
base = projects.projects.filter {
|
||
$0.name.lowercased().contains(needle)
|
||
|| $0.path.lowercased().contains(needle)
|
||
|| $0.aliases.contains { $0.lowercased().contains(needle) }
|
||
|| ($0.description?.lowercased().contains(needle) ?? false)
|
||
}
|
||
}
|
||
// 置顶 > 正在开发 > 最近提交 > 名字。38 个项目按 yaml 顺序排的话,
|
||
// 找一个要翻半屏 —— 而人真正会回去的就是在跑的和最近动过的那几个。
|
||
return ProjectOrdering.rank(
|
||
base,
|
||
pinned: projects.pinned,
|
||
runningCount: { runningCount($0) },
|
||
lastCommitAt: { projects.git(for: $0)?.lastCommitAt }
|
||
)
|
||
}
|
||
|
||
/// 这个项目下有没有正在跑的会话。
|
||
private func runningCount(_ project: Project) -> Int {
|
||
sessions(of: project).count
|
||
}
|
||
|
||
/// 其中有多少是「终端已经关了、进程还在后台跑」的。
|
||
private func detachedCount(_ project: Project) -> Int {
|
||
sessions(of: project).count { store.detachedSessionIds.contains($0.sessionId) }
|
||
}
|
||
|
||
private func sessions(of project: Project) -> [AgentSession] {
|
||
let root = project.expandedPath
|
||
return store.sessions.filter { $0.cwd == root || $0.cwd.hasPrefix(root + "/") }
|
||
}
|
||
|
||
/// 选一个已有目录登记成项目。
|
||
///
|
||
/// **只登记,不创建、不改动目录本身。**「新建」是另一个按钮。
|
||
private func addExistingDirectory() {
|
||
let panel = NSOpenPanel()
|
||
panel.canChooseDirectories = true
|
||
panel.canChooseFiles = false
|
||
panel.allowsMultipleSelection = true
|
||
panel.prompt = "添加"
|
||
panel.message = "选择要加入清单的项目目录(不限于 git 仓库)"
|
||
panel.directoryURL = URL(
|
||
fileURLWithPath: NSString(string: "~/Documents/code").expandingTildeInPath
|
||
)
|
||
guard panel.runModal() == .OK else { return }
|
||
for url in panel.urls { projects.addExisting(path: url.path) }
|
||
}
|
||
|
||
/// 这一行的菜单内容。▶ 按钮和右键共用。
|
||
private func menuItems(for project: Project) -> [RowMenu.Item?] {
|
||
ProjectMenu.items(
|
||
isPinned: projects.isPinned(project),
|
||
resumable: runningCount(project) == 0
|
||
? closedSessions.latest(forProject: project.expandedPath)
|
||
: nil,
|
||
isMissing: projects.isMissing(project),
|
||
onLaunch: { onLaunch(project, $0) },
|
||
onTogglePin: { projects.togglePin(project) },
|
||
onRemove: { projects.remove([project]) },
|
||
onCopySecretPath: secrets.groups(for: project).isEmpty ? nil : {
|
||
Clipboard.copy(secrets.envFilePath(for: project))
|
||
}
|
||
)
|
||
}
|
||
|
||
/// 一行的会话说明。
|
||
///
|
||
/// **全部会话都断开时不能只写「N 个会话」** —— 用户已经把窗口关了,
|
||
/// 看到一个亮着的蓝点只会认为界面在撒谎(实机上就是这么被指出来的)。
|
||
/// 真相是进程还在跑,得原样说出来。
|
||
static func sessionSummary(running: Int, detached: Int) -> String? {
|
||
guard running > 0 else { return nil }
|
||
if detached == 0 { return "\(running) 个会话" }
|
||
if detached == running {
|
||
return running == 1 ? "1 个会话 · 终端已关" : "\(running) 个会话 · 终端都已关"
|
||
}
|
||
return "\(running) 个会话 · \(detached) 个终端已关"
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
PaneHeader(title: "项目", subtitle: "\(projects.projects.count) 个项目")
|
||
|
||
HStack(spacing: 10) {
|
||
TextField("搜索项目…", text: $search)
|
||
.textFieldStyle(.roundedBorder)
|
||
Button {
|
||
showNewProject = true
|
||
} label: {
|
||
Label("新建", systemImage: "plus")
|
||
}
|
||
Button {
|
||
showClone = true
|
||
} label: {
|
||
Label("克隆", systemImage: "arrow.down.circle")
|
||
}
|
||
Button {
|
||
projects.scan()
|
||
} label: {
|
||
Label("扫描", systemImage: "arrow.clockwise")
|
||
}
|
||
.disabled(projects.isScanning)
|
||
// 扫描只认有 .git 的目录 —— monorepo 里的子目录、还没 git init
|
||
// 的目录永远扫不进来(实机 40 条里有 7 条是这种)。
|
||
// 这是"能加载进去"真正缺的那个入口。
|
||
Button {
|
||
addExistingDirectory()
|
||
} label: {
|
||
Label("添加目录", systemImage: "folder.badge.plus")
|
||
}
|
||
if !projects.missingProjects.isEmpty {
|
||
Button(role: .destructive) {
|
||
projects.remove(projects.missingProjects)
|
||
} label: {
|
||
Label(
|
||
"清理失效 \(projects.missingProjects.count)",
|
||
systemImage: "trash"
|
||
)
|
||
}
|
||
.help("这些项目的目录已经不在了。只从清单里移除,不动磁盘上的任何文件。")
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 12)
|
||
.sheet(isPresented: $showNewProject) {
|
||
NewProjectSheet(projects: projects)
|
||
}
|
||
.sheet(isPresented: $showClone) {
|
||
CloneProjectSheet(projects: projects, git: git)
|
||
}
|
||
|
||
if projects.projects.isEmpty {
|
||
emptyState
|
||
Spacer()
|
||
}
|
||
|
||
ScrollView {
|
||
LazyVStack(spacing: 8) {
|
||
ForEach(filtered) { project in
|
||
Card {
|
||
HStack(spacing: 12) {
|
||
// 全断开时用空心点:一眼能和「真的在跑」区分开,
|
||
// 又不至于和「没有会话」混成一样。
|
||
SessionDot(
|
||
running: runningCount(project),
|
||
detached: detachedCount(project)
|
||
)
|
||
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 8) {
|
||
Text(project.name)
|
||
.font(.system(size: 14, weight: .semibold,
|
||
design: .rounded))
|
||
if projects.isPinned(project) {
|
||
Image(systemName: "pin.fill")
|
||
.font(.system(size: 9))
|
||
.foregroundStyle(.secondary)
|
||
.rotationEffect(.degrees(45))
|
||
}
|
||
if projects.isMissing(project) {
|
||
Label("目录已不存在", systemImage:
|
||
"exclamationmark.triangle.fill")
|
||
.font(.system(size: 10, weight: .medium))
|
||
.foregroundStyle(IslandTheme.waiting)
|
||
}
|
||
if let summary = Self.sessionSummary(
|
||
running: runningCount(project),
|
||
detached: detachedCount(project)
|
||
) {
|
||
Text(summary)
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(
|
||
detachedCount(project) == runningCount(project)
|
||
? Color.secondary : IslandTheme.busy
|
||
)
|
||
}
|
||
}
|
||
if let description = project.description {
|
||
Text(description)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
// 「关窗即结束」只有在还能接回来时才成立。
|
||
// 藏在 ▶ 菜单第三层的入口等于没有 ——
|
||
// 用户关掉窗口后第一眼看的就是这一行。
|
||
if runningCount(project) == 0,
|
||
let last = closedSessions.latest(forProject:
|
||
project.expandedPath) {
|
||
Button {
|
||
onLaunch(project, .resumeSession)
|
||
} label: {
|
||
Label(
|
||
"接着上次(\(RelativeTime.short(from: last.endedAt))前结束)",
|
||
systemImage: "arrow.uturn.backward.circle"
|
||
)
|
||
.font(.system(size: 11))
|
||
}
|
||
.buttonStyle(.link)
|
||
.help("恢复 \(last.name),接着上次的上下文往下走")
|
||
}
|
||
Text(project.path)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundStyle(.tertiary)
|
||
.strikethrough(projects.isMissing(project))
|
||
.lineLimit(1)
|
||
.truncationMode(.head)
|
||
}
|
||
|
||
Spacer(minLength: 8)
|
||
GitChip(info: projects.git(for: project))
|
||
|
||
Menu {
|
||
// 和右键、和岛上那份是同一批菜单项。
|
||
// 各写一遍必然漂移,见 ProjectMenu 的说明。
|
||
let items = menuItems(for: project)
|
||
ForEach(items.indices, id: \.self) { index in
|
||
if let item = items[index] {
|
||
Button(item.title, action: item.action)
|
||
} else {
|
||
Divider()
|
||
}
|
||
}
|
||
} label: {
|
||
Image(systemName: "play.circle")
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.fixedSize()
|
||
}
|
||
// 右键整行 = 同一个菜单。每次都得瞄准行尾那个小按钮
|
||
// 才能选开法,是这一版之前最鸡肋的地方。
|
||
.overlay(RightClickCatcher {
|
||
RowMenu.present(menuItems(for: project))
|
||
})
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 20)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 一个项目都没有时的空状态。
|
||
///
|
||
/// **必须说清楚在读哪个文件。** 这次的事故是 app 读的 projects.yaml 和
|
||
/// 脚本维护的那份不是同一个,而界面上只有一个孤零零的"0 个项目",
|
||
/// 完全没有线索。空状态要给出路径、原因和下一步动作。
|
||
private var emptyState: some View {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Label("没有读到项目", systemImage: "exclamationmark.triangle")
|
||
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||
.foregroundStyle(IslandTheme.waiting)
|
||
|
||
if let diagnostic = projects.loadDiagnostic {
|
||
Text(diagnostic)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text("数据文件")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.tertiary)
|
||
Text(projects.sourceURL?.path ?? "(找不到 projects.yaml)")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
}
|
||
|
||
HStack(spacing: 8) {
|
||
if let url = projects.sourceURL {
|
||
Button("在 Finder 中显示") {
|
||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||
}
|
||
}
|
||
Button("重新加载") { projects.load() }
|
||
Button("扫描 ~/Documents/code") { projects.scan() }
|
||
.disabled(projects.isScanning)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
}
|
||
}
|
||
|
||
// MARK: - 用量
|
||
|
||
struct UsagePane: View {
|
||
/// 默认 7 天而不是全部。
|
||
///
|
||
/// `~/.claude/projects` 实测有近 1 GB / 341 个 jsonl,全量首扫要好几十秒。
|
||
/// 打开页面就卡住不出数,用户读到的是"这个功能没做"。7 天视图靠 mtime
|
||
/// 过滤掉绝大多数文件,首屏两秒内出结果;想看全量再自己切。
|
||
@State private var range: UsageRange = .week7
|
||
@State private var summary: UsageSummary = .empty
|
||
@State private var loading = false
|
||
@State private var scanned = 0
|
||
@State private var total = 0
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
PaneHeader(
|
||
title: "用量",
|
||
subtitle: "\(summary.totalSessions) 个会话 · \(summary.totalMessages) 条消息"
|
||
)
|
||
|
||
Picker("", selection: $range) {
|
||
ForEach(UsageRange.allCases, id: \.self) { Text($0.rawValue).tag($0) }
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.labelsHidden()
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 12)
|
||
|
||
// 全量首扫要读近 1 GB。没有进度的话用户没法区分"在算"和"坏了"。
|
||
if loading {
|
||
HStack(spacing: 8) {
|
||
ProgressView().controlSize(.small)
|
||
Text(total > 0 ? "正在统计 \(scanned)/\(total) 个会话文件…" : "正在统计…")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 10)
|
||
}
|
||
|
||
HStack(spacing: 10) {
|
||
statTile("输入", summary.totalInput, IslandTheme.busy)
|
||
statTile("输出", summary.totalOutput, IslandTheme.shell)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 12)
|
||
|
||
ScrollView {
|
||
LazyVStack(spacing: 6) {
|
||
ForEach(summary.projects) { usage in
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack {
|
||
Text(usage.projectName)
|
||
.font(.system(size: 13, weight: .semibold,
|
||
design: .rounded))
|
||
.lineLimit(1)
|
||
Spacer()
|
||
Text(format(usage.totalTokens))
|
||
.font(.system(size: 12)).monospacedDigit()
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
GeometryReader { proxy in
|
||
let maxTokens = summary.projects.first?.totalTokens ?? 1
|
||
let ratio = max(
|
||
0.01, Double(usage.totalTokens) / Double(max(maxTokens, 1))
|
||
)
|
||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [
|
||
IslandTheme.busy.opacity(0.85),
|
||
IslandTheme.busy.opacity(0.35),
|
||
],
|
||
startPoint: .leading, endPoint: .trailing
|
||
)
|
||
)
|
||
.frame(width: proxy.size.width * ratio)
|
||
}
|
||
.frame(height: 6)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 20)
|
||
}
|
||
}
|
||
.task(id: range) { await reload() }
|
||
}
|
||
|
||
private func statTile(_ label: String, _ value: Int, _ color: Color) -> some View {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(label).font(.system(size: 11)).foregroundStyle(.secondary)
|
||
Text(format(value))
|
||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||
.monospacedDigit()
|
||
.foregroundStyle(color)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func format(_ value: Int) -> String {
|
||
if value >= 1_000_000 { return String(format: "%.1fM", Double(value) / 1_000_000) }
|
||
if value >= 1_000 { return String(format: "%.1fk", Double(value) / 1_000) }
|
||
return "\(value)"
|
||
}
|
||
|
||
private func reload() async {
|
||
loading = true
|
||
scanned = 0
|
||
total = 0
|
||
let range = self.range
|
||
|
||
// 解析要扫近 1 GB 的 jsonl,必须放后台;进度回调也从后台线程来,
|
||
// 所以要显式跳回 MainActor 再更新 @State。
|
||
let result = await Task.detached(priority: .userInitiated) {
|
||
UsageStats.collect(range: range) { done, count in
|
||
Task { @MainActor in
|
||
scanned = done
|
||
total = count
|
||
}
|
||
}
|
||
}.value
|
||
|
||
summary = result
|
||
loading = false
|
||
}
|
||
}
|
||
|
||
// MARK: - 审批日志
|
||
|
||
struct ApprovalLogPane: View {
|
||
let approvals: ApprovalCoordinator
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
PaneHeader(
|
||
title: "审批日志",
|
||
subtitle: "记录所有被识别为高风险的操作。看一周就知道该不该开启拦截。"
|
||
)
|
||
|
||
if approvals.log.isEmpty {
|
||
ContentUnavailableView(
|
||
"还没有记录",
|
||
systemImage: "checkmark.shield",
|
||
description: Text("高风险操作发生时会记在这里")
|
||
)
|
||
} else {
|
||
ScrollView {
|
||
LazyVStack(spacing: 6) {
|
||
ForEach(approvals.log) { entry in
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
HStack(spacing: 8) {
|
||
Text(entry.risk == .irreversible ? "不可逆" : "危险")
|
||
.font(.system(size: 10, weight: .bold))
|
||
.foregroundStyle(.black)
|
||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||
.background(
|
||
entry.risk == .irreversible
|
||
? IslandTheme.danger : IslandTheme.waiting,
|
||
in: .capsule
|
||
)
|
||
Text(entry.projectName)
|
||
.font(.system(size: 13, weight: .semibold))
|
||
Text(entry.toolName)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
Text(verdictLabel(entry))
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(verdictColor(entry))
|
||
Text(entry.timestamp, style: .time)
|
||
.font(.system(size: 11)).monospacedDigit()
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
Text(entry.command)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(2)
|
||
.textSelection(.enabled)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 20)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func verdictLabel(_ entry: ApprovalLogEntry) -> String {
|
||
switch entry.verdict {
|
||
case "allow": return "已允许"
|
||
case "deny": return "已拒绝"
|
||
case "timeout": return "超时拒绝"
|
||
default: return entry.intercepted ? entry.verdict : "未拦截"
|
||
}
|
||
}
|
||
|
||
private func verdictColor(_ entry: ApprovalLogEntry) -> Color {
|
||
switch entry.verdict {
|
||
case "deny", "timeout": return IslandTheme.danger
|
||
case "allow": return IslandTheme.shell
|
||
default: return .secondary
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 设置
|
||
|
||
struct SettingsPane: View {
|
||
@Bindable var approvals: ApprovalCoordinator
|
||
let projects: ProjectStore
|
||
let git: GitAccountStore
|
||
@Bindable var placement: IslandPlacementStore
|
||
let channels: HookChannelMonitor
|
||
@Bindable var verifierSettings: VerifierSettings
|
||
@Bindable var reaper: SessionReaper
|
||
|
||
@State private var hookStatus: String = "检查中…"
|
||
/// hook 装好了没有。启动时 app 已经自动装过一遍,这里是复查 + 手动修复入口。
|
||
@State private var hookInstalled: HookInstaller.Outcome = .alreadyCorrect
|
||
|
||
/// 「关掉终端窗口就结束会话」。
|
||
///
|
||
/// **必须能关。** 这是全案第二个会主动杀进程的组件,一个杀错了没法
|
||
/// 阻止的自动行为不该存在。文案要把两侧的代价都说清楚:
|
||
/// 开着 = ⌘W 手滑会弄丢正在跑的活;关着 = 会话在后台留着,
|
||
/// 界面上标成"终端已关"但不动它。
|
||
@ViewBuilder
|
||
private var reaperCard: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Toggle("关掉终端窗口时结束会话", isOn: $reaper.enabled)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
|
||
Text("""
|
||
tmux 默认的行为是关窗口只 detach,claude 进程照跑 —— 于是界面上一直\
|
||
显示"在跑"而你已经把窗口关了。打开这个开关后,检测到没有终端连着\
|
||
就结束它,**结束前会把 sessionId 记下来**,项目行上会出现「接着上次」\
|
||
一键接回原来的上下文。
|
||
""")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
Text("""
|
||
代价:⌘W 手滑会结束正在跑的活(连续两轮、共 30 秒确认,\
|
||
会话起来不足 90 秒不动)。关掉这个开关则什么都不杀,\
|
||
只在项目行上标成"终端已关"。
|
||
""")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(reaper.enabled ? IslandTheme.waiting : Color.secondary.opacity(0.6))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
/// 「Hub 自己跑验收命令」的总开关。
|
||
///
|
||
/// 文案上刻意把三道闸都说出来。这是全案唯一会执行命令的功能,
|
||
/// 用户有权知道边界在哪 —— 只写一句"自动验证"然后让他猜,
|
||
/// 结果只会是他不敢开,或者开了但不知道自己开了什么。
|
||
@ViewBuilder
|
||
private var verifierCard: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Toggle("自动跑验收命令(实际功能证明)", isOn: $verifierSettings.enabled)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
|
||
Text("""
|
||
关掉时 Vibe Foreman 一条命令都不会执行。打开后也仍有两道限制:\
|
||
只放行构建/测试类命令(`swift test`、`npm run`、`bash scripts/*` 等),\
|
||
含 shell 元字符的一律拒绝;而且**每条命令首次执行前要你在验收页点「允许」**。
|
||
""")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
|
||
Text("这是 Vibe Foreman 唯一会在你机器上执行命令的功能。")
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(
|
||
verifierSettings.enabled ? IslandTheme.waiting : Color.secondary.opacity(0.6)
|
||
)
|
||
}
|
||
}
|
||
|
||
/// hook 通道健康度。
|
||
///
|
||
/// `hubctl doctor` 只能回答"socket 通不通"。但链路断掉的方式远不止这一种:
|
||
/// settings.json 被别的工具覆盖、event 名字拼错、Claude Code 改了名 ——
|
||
/// 这几种情况下 socket 是通的、doctor 是绿的,而那一类事件一条都收不到,
|
||
/// 验收清单会安静地不工作。
|
||
///
|
||
/// **只有真收到过事件才能证明通了。** 所以这里显示的是实际到达数,不是配置项。
|
||
@ViewBuilder
|
||
private var hookChannels: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("Hook 通道")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
Text("显示的是**实际收到过的事件数**,不是配置里写了什么 —— 配置对而链路断是最常见的故障。")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
|
||
ForEach(HookChannelMonitor.expected, id: \.self) { kind in
|
||
let count = channels.counts[kind] ?? 0
|
||
HStack(spacing: 8) {
|
||
Circle()
|
||
.fill(count > 0 ? IslandTheme.shell : Color.secondary.opacity(0.35))
|
||
.frame(width: 6, height: 6)
|
||
Text(HookChannelMonitor.label(for: kind))
|
||
.font(.system(size: 12))
|
||
Spacer()
|
||
if let last = channels.lastSeen[kind] {
|
||
Text(last, style: .relative)
|
||
.font(.system(size: 11)).monospacedDigit()
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
Text(count > 0 ? "\(count)" : "未收到")
|
||
.font(.system(size: 11, weight: count > 0 ? .medium : .regular))
|
||
.monospacedDigit()
|
||
.foregroundStyle(count > 0 ? .secondary : .tertiary)
|
||
}
|
||
}
|
||
|
||
Text("计数从 Vibe Foreman 启动时开始。刚装完 hook 的话,新开一个会话才会亮。")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.tertiary)
|
||
.padding(.top, 2)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
PaneHeader(title: "设置", subtitle: nil)
|
||
|
||
ScrollView {
|
||
VStack(spacing: 12) {
|
||
Card { reaperCard }
|
||
Card { verifierCard }
|
||
Card { hookChannels }
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("灵动岛位置").font(.system(size: 14, weight: .semibold))
|
||
Toggle("跟随光标去外接屏", isOn: $placement.followsCursor)
|
||
.toggleStyle(.checkbox)
|
||
|
||
Text("""
|
||
默认固定在有刘海的那块屏。开了之后岛会跟着光标跑到外接屏 —— \
|
||
但外接屏没有刘海,岛会变成悬浮在屏幕顶部的胶囊,正好压在\
|
||
浏览器标签栏那一带,底下的东西会点不动。想在外接屏上也看到\
|
||
状态再开。
|
||
""")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("审批拦截").font(.system(size: 14, weight: .semibold))
|
||
Picker("拦截门槛", selection: $approvals.interceptThreshold) {
|
||
Text("只拦不可逆操作(推荐)").tag(RiskLevel.irreversible)
|
||
Text("危险操作也拦").tag(RiskLevel.dangerous)
|
||
}
|
||
.pickerStyle(.radioGroup)
|
||
|
||
Text("""
|
||
你全局用的是 auto 权限模式 + --dangerously-skip-permissions,\
|
||
图的就是不被打断。把「危险操作」一档也拦上,按日常用量每天会弹 3–8 次。\
|
||
建议先看一周审批日志再决定。
|
||
""")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Text("Git 账号").font(.system(size: 14, weight: .semibold))
|
||
Spacer()
|
||
Button {
|
||
git.refresh()
|
||
} label: {
|
||
Image(systemName: "arrow.clockwise")
|
||
}
|
||
.disabled(git.isRefreshing)
|
||
}
|
||
|
||
if git.accounts.isEmpty {
|
||
Text(git.diagnostic ?? (git.isRefreshing ? "检查中…" : "未检查"))
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(git.accounts) { account in
|
||
HStack(spacing: 6) {
|
||
Image(systemName: account.active
|
||
? "person.crop.circle.badge.checkmark"
|
||
: "person.crop.circle")
|
||
.foregroundStyle(account.active ? .green : .secondary)
|
||
Text("\(account.login) · \(account.host)")
|
||
.font(.system(size: 12, design: .monospaced))
|
||
if account.active {
|
||
Text("当前")
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(.green)
|
||
if !git.repos.isEmpty {
|
||
Text("\(git.repos.count) 个仓库")
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
} else {
|
||
Button("切换到这个") { git.switchTo(account) }
|
||
.font(.system(size: 11))
|
||
.disabled(git.isSwitching || git.isRefreshing)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Text("克隆用的是标「当前」的那个账号的凭据 —— 拉另一个账号的私有仓要先切过去。新增账号在终端跑:")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
HStack(spacing: 8) {
|
||
Text("gh auth login")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundStyle(.tertiary)
|
||
Button("复制命令") {
|
||
Clipboard.copy("gh auth login")
|
||
}
|
||
.font(.system(size: 11))
|
||
}
|
||
}
|
||
}
|
||
.onAppear { git.refresh() }
|
||
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Text("Hook 安装").font(.system(size: 14, weight: .semibold))
|
||
Spacer()
|
||
Button("重新检查并修复") { hookInstalled = HookInstaller().install() }
|
||
.font(.system(size: 11))
|
||
}
|
||
|
||
// 自动装好之后必须**说出来**。一个默默做完的自动步骤
|
||
// 和一个默默失败的自动步骤,在界面上长得一模一样;
|
||
// 而后者的表现是"app 开着但一条事件都收不到"。
|
||
switch hookInstalled {
|
||
case .alreadyCorrect:
|
||
Label("9 类 hook 已装好,无需手动操作", systemImage: "checkmark.seal")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(IslandTheme.shell)
|
||
case .installed(let count):
|
||
Label("已自动装好 \(count) 类 hook", systemImage: "wand.and.stars")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(IslandTheme.busy)
|
||
case .failed(let reason):
|
||
Label(reason, systemImage: "exclamationmark.triangle.fill")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(IslandTheme.waiting)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
Text("装到 ~/.claude/settings.json,只动 Hub 自己的那几条,你手写的 hook 不受影响。")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.tertiary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
Divider().padding(.vertical, 2)
|
||
|
||
Text("Hook 连接").font(.system(size: 14, weight: .semibold))
|
||
Text(hookStatus)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
Text("socket:\(HubIPCPath.display)")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundStyle(.tertiary)
|
||
.textSelection(.enabled)
|
||
}
|
||
}
|
||
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("项目配置").font(.system(size: 14, weight: .semibold))
|
||
Text("\(projects.projects.count) 个项目")
|
||
.font(.system(size: 12)).foregroundStyle(.secondary)
|
||
|
||
// 把实际读的文件路径摆出来。踩过的坑:脚本维护的是
|
||
// 仓库里那份 projects.yaml,app 读的却是
|
||
// Application Support 里另一份旧的,界面上完全没有线索。
|
||
Text(projects.sourceURL?.path ?? "(找不到 projects.yaml)")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundStyle(.tertiary)
|
||
.textSelection(.enabled)
|
||
.lineLimit(1)
|
||
.truncationMode(.head)
|
||
|
||
if let diagnostic = projects.loadDiagnostic {
|
||
Text(diagnostic)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(IslandTheme.waiting)
|
||
}
|
||
|
||
Button("重新加载 projects.yaml") { projects.load() }
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 20)
|
||
}
|
||
}
|
||
.onAppear {
|
||
hookStatus = "✓ socket 正在监听,hook 事件会实时送达"
|
||
// 复查而不是重装:进设置页只是想看看状态,不该顺手改文件。
|
||
let installer = HookInstaller()
|
||
hookInstalled = installer.isHealthy()
|
||
? .alreadyCorrect
|
||
: .failed("有 hook 没装上或指向了旧的 hubctl —— 点右上角修复")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 给设置页显示 socket 路径用。放这里避免 HubUI 直接暴露 HubIPC 的类型给外部。
|
||
enum HubIPCPath {
|
||
static var display: String {
|
||
FileManager.default.homeDirectoryForCurrentUser
|
||
.appendingPathComponent("Library/Application Support/claude-hub/hub.sock")
|
||
.path
|
||
}
|
||
}
|