两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。 **共用密钥**:平台密钥(飞书、领星、OpenAI)在十几个项目里要各配一遍, 而最省事的做法——直接粘进聊天框告诉 AI——是最糟的那个。现在按平台建组、 勾给项目,物化成 ~/.vibe-foreman/env/by-project/<项目>-<哈希>.env (目录 0700、文件 0600),复制路径给 AI。这份数据**故意是明文、故意给 AI 用**。 **账号密码**:本地/测试/线上分开记,只给人看。AES-GCM 加密, 查看和复制过 Touch ID。这份数据**绝不能被任何自动化读到**。 两者不共用存储、不共用目录、不共用界面,并用一条哨兵测试钉死 「密码的值不会出现在给 AI 的那个目录里」。 三个决定了做法的发现: 1. 「让 AI Read 那个文件」会把密钥写进两个明文文件—— ~/.claude/projects/*.jsonl(0644、永久、每轮重发给 API), 以及 Hub 自己的验收清单(Evidence.ran 会存命令和输出尾巴,同样不过期)。 所以「复制给 AI」给的不是裸路径,是带 `set -a; source` 用法和 「不要用 Read/cat/grep」禁令的三句话。换成 source 之后, transcript 里只剩路径和变量名,值一次都不出现。 2. 按项目分文件**是分装不是隔离**——同 uid 下 cat ../别的.env 照样拿得到。 README 和代码注释都按分装写,不吹成边界。 3. Keychain 的授权弹窗**就是那道防线**。SecItemAdd 不传 kSecAttrAccess 时, 默认 ACL 只允许创建它的 app 无提示读取,而在「攻击者 = 同 uid 的自动化」 这个模型下,能区分「Hub 在读」和「security 命令在读」的只有它—— 文件权限做不到(同 uid),Touch ID 也做不到(LAContext 不参与取密钥)。 为了消弹窗放宽 ACL,等于一条 Bash 就能取走主密钥。 几个不写下来一定会再踩的坑(已进 NOTES.md): - .atomic 写换 inode,chmod 必须每次都做,只在创建时设一次从第二次写起就是 0644 - GCM 的 AAD 必须用文件里存下来的那段字节,重新序列化会让历史文件永久打不开 - 「钥匙不对」和「文件坏了」处置完全相反,靠 header 里的 keyID 分辨; EncryptedFile.save() 的 guard state.canWrite 是这套区分唯一的落地点 - LAContext 复用才有 reuse duration,每次新建等于每条密码按一次指纹 - 剪贴板定时清空必须比对 changeCount,否则用户复制的别的东西会被莫名清掉 - .env 的引号:shell / node dotenv / python-dotenv 三家的交集只有单引号; 多行值用字面换行不能用 \n;含单引号的值没有共同解,改写成 _B64 显式降级 617 个测试全过(原 530)。真 bash `set -a; source` 验过含空格、$、反引号、 #、多行 PEM 和含单引号的值。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1417 lines
58 KiB
Swift
1417 lines
58 KiB
Swift
import HubCore
|
||
import HubProbe
|
||
import HubProjects
|
||
import SwiftUI
|
||
|
||
/// 灵动岛的根视图。
|
||
///
|
||
/// 结构:`玻璃外壳` → `scrim` → `内容`。
|
||
///
|
||
/// 形态来自 `IslandModel`(`@Observable`),**不是**通过替换 rootView 切换的 ——
|
||
/// 那样做的话 SwiftUI 看到的是两棵不相干的树,动画无从发生、`@State` 每次归零。
|
||
public struct IslandRootView: View {
|
||
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||
|
||
private let store: SessionStore
|
||
private let approvals: ApprovalCoordinator
|
||
private let prompts: AgentPromptCoordinator
|
||
private let projects: ProjectStore
|
||
private let closedSessions: ClosedSessionStore
|
||
private let model: IslandModel
|
||
private let geometry: NotchGeometry
|
||
private let onToggleExpand: () -> Void
|
||
private let onSelect: (AgentSession) -> Void
|
||
private let onFocus: (AgentSession) -> Void
|
||
private let onCollapse: () -> Void
|
||
private let onLaunch: (Project, LaunchMode) -> Void
|
||
private let onPickStalled: (StallFinding) -> Void
|
||
private let onReply: (String) -> Void
|
||
private let onDialogKey: (AgentPromptRequest, TerminalReply.DialogKey) -> Void
|
||
/// 问某个项目的共用密钥路径。nil(闭包本身或者返回值)= 菜单里不给这一项。
|
||
private let onSecretPath: ((Project) -> String?)?
|
||
|
||
@Namespace private var glassNamespace
|
||
|
||
public init(
|
||
store: SessionStore,
|
||
approvals: ApprovalCoordinator,
|
||
prompts: AgentPromptCoordinator,
|
||
projects: ProjectStore,
|
||
closedSessions: ClosedSessionStore,
|
||
model: IslandModel,
|
||
geometry: NotchGeometry,
|
||
onToggleExpand: @escaping () -> Void,
|
||
onSelect: @escaping (AgentSession) -> Void,
|
||
onFocus: @escaping (AgentSession) -> Void,
|
||
onCollapse: @escaping () -> Void,
|
||
onLaunch: @escaping (Project, LaunchMode) -> Void,
|
||
onPickStalled: @escaping (StallFinding) -> Void = { _ in },
|
||
onReply: @escaping (String) -> Void = { _ in },
|
||
onDialogKey: @escaping (AgentPromptRequest, TerminalReply.DialogKey) -> Void = { _, _ in },
|
||
onSecretPath: ((Project) -> String?)? = nil
|
||
) {
|
||
self.onSecretPath = onSecretPath
|
||
self.onPickStalled = onPickStalled
|
||
self.onReply = onReply
|
||
self.onDialogKey = onDialogKey
|
||
self.store = store
|
||
self.approvals = approvals
|
||
self.prompts = prompts
|
||
self.projects = projects
|
||
self.closedSessions = closedSessions
|
||
self.model = model
|
||
self.geometry = geometry
|
||
self.onToggleExpand = onToggleExpand
|
||
self.onSelect = onSelect
|
||
self.onFocus = onFocus
|
||
self.onCollapse = onCollapse
|
||
self.onLaunch = onLaunch
|
||
}
|
||
|
||
private var state: IslandState { model.state }
|
||
|
||
private var metrics: IslandMetrics {
|
||
// `.nudge` 的宽度跟着**卡住的会话数**走,不是总会话数 ——
|
||
// 用总数算的话,9 个会话里只有 1 个卡住时岛会宽得离谱,中间全是空的。
|
||
let count = state == .nudge ? model.nudge.count : store.sessions.count
|
||
return IslandMetrics.metrics(
|
||
for: state, geometry: geometry, sessionCount: count
|
||
)
|
||
}
|
||
|
||
private var shape: IslandShape {
|
||
IslandShape(
|
||
notchWidth: geometry.notchWidth,
|
||
notchHeight: geometry.notchHeight,
|
||
bodyWidth: metrics.bodyWidth,
|
||
bodyHeight: metrics.bodyHeight,
|
||
bottomRadius: metrics.bottomRadius
|
||
)
|
||
}
|
||
|
||
/// 内容区顶部让出的高度。有刘海时让出刘海本身(那块被物理遮挡,画什么都看不见),
|
||
/// 无刘海时让出胶囊的悬浮间距。
|
||
private var contentTopInset: CGFloat {
|
||
geometry.hasNotch ? geometry.notchHeight : 8
|
||
}
|
||
|
||
/// 开发期定位用:HUB_DEBUG_CONTENT=1 时给内容框描一层可见底色,
|
||
/// 一眼看出它到底被放在哪、有没有被裁掉。
|
||
private var debugTint: Color {
|
||
ProcessInfo.processInfo.environment["HUB_DEBUG_CONTENT"] == "1"
|
||
? Color.red.opacity(0.25)
|
||
: Color.clear
|
||
}
|
||
|
||
public var body: some View {
|
||
ZStack(alignment: .top) {
|
||
shell
|
||
contentLayer
|
||
}
|
||
.frame(
|
||
width: IslandMetrics.panelSize.width,
|
||
height: IslandMetrics.panelSize.height,
|
||
alignment: .topLeading
|
||
)
|
||
// 岛永远暗色,不跟随系统。不是审美偏好,是物理约束:岛的上半截必须和刘海
|
||
// 严丝合缝,而刘海在浅色模式下也是纯黑 —— 做浅色岛会在刘海下沿留一条
|
||
// 永远消不掉的黑白硬边。
|
||
.environment(\.colorScheme, .dark)
|
||
}
|
||
|
||
// MARK: - 外壳
|
||
|
||
@ViewBuilder
|
||
private var shell: some View {
|
||
ZStack(alignment: .topLeading) {
|
||
if reduceTransparency {
|
||
// 完全兜底:关掉透明度后材质没有意义,换成接近不透明的实心底。
|
||
shape
|
||
.fill(Color.black.opacity(0.97))
|
||
.overlay(shape.stroke(.white.opacity(0.14), lineWidth: 1))
|
||
} else {
|
||
// 自控的深色材质,**不是** Liquid Glass —— 完整理由见 IslandBackdrop。
|
||
IslandBackdrop()
|
||
.clipShape(shape)
|
||
|
||
// 固定的暗色基底。这一层决定了岛的颜色,**不参与任何自适应**。
|
||
//
|
||
// 绕不开的算术:任何"能透出背景"的材质,在白底上都必然比在黑底上亮。
|
||
// 试过 Liquid Glass(`.regular` / `.clear` + tint)和
|
||
// NSVisualEffectView 的多种 material,白底下输出都落在 0.5~0.7,
|
||
// 差别只是程度。**要在任何背景下都贴近刘海,就只能接近不透明。**
|
||
//
|
||
// 最终亮度 ≈ 背景亮度 × (1 − IslandTheme.baseOpacity)
|
||
// 白底 (1.0) → 0.14 黑底 (0.0) → 0.0 刘海 → 0.0
|
||
//
|
||
// 留下的这 14% 正是"还能看出背后有东西"的量:透光感还在,
|
||
// 但不足以把岛拉离刘海的色系。玻璃质感由背景模糊 + 边缘高光承担。
|
||
shape
|
||
.fill(Color.black.opacity(IslandTheme.baseOpacity))
|
||
.allowsHitTesting(false)
|
||
}
|
||
|
||
// 闯入 / 审批的状态色。
|
||
//
|
||
// 染在材质**之上**而不是作为材质的 tint,这样它不受背景亮度影响,
|
||
// 任何壁纸下都是同一个颜色 —— 状态色的作用是传达"出事了",
|
||
// 它必须是可预期的常量。
|
||
if let shellTint {
|
||
shape.fill(shellTint).allowsHitTesting(false)
|
||
}
|
||
|
||
// scrim:可读性主力,见 IslandTheme.scrim 的说明。
|
||
shape
|
||
.fill(IslandTheme.scrim(for: state))
|
||
.allowsHitTesting(false)
|
||
|
||
// 刘海 → 岛的过渡。
|
||
//
|
||
// 这一层专治"岛和刘海之间有一条明显分界线":纯黑一直铺到刘海下沿,
|
||
// 再往下用 34pt 渐隐掉。没有它的话,不透光的刘海和半透明的玻璃
|
||
// 直接相接,边界处的亮度落差会把"岛是另外贴上去的一块东西"暴露无遗。
|
||
if geometry.hasNotch {
|
||
shape
|
||
.fill(IslandTheme.notchBlend(
|
||
notchHeight: geometry.notchHeight,
|
||
panelHeight: IslandMetrics.panelSize.height
|
||
))
|
||
.allowsHitTesting(false)
|
||
}
|
||
|
||
// 边缘高光。玻璃感的关键一笔:真实玻璃的边缘因折射而比中间亮,
|
||
// 下沿尤其明显。没有这条边,再透明也只会被读成一块半透明色块。
|
||
shape
|
||
.stroke(IslandTheme.edgeHighlight, lineWidth: 1)
|
||
.allowsHitTesting(false)
|
||
|
||
// 刘海遮蔽:物理刘海就是纯黑,这块区域画玻璃只会露馅(玻璃会透出
|
||
// 桌面的颜色,而刘海是不透光的实体,两者对不上就会看出破绽)。
|
||
// 画在最上层,顺便盖掉边缘高光在刘海段可能漏出的那一道亮线。
|
||
if geometry.hasNotch {
|
||
Rectangle()
|
||
.fill(.black)
|
||
.frame(width: geometry.notchWidth, height: geometry.notchHeight)
|
||
.offset(
|
||
x: (IslandMetrics.panelSize.width - geometry.notchWidth) / 2,
|
||
y: 0
|
||
)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
.frame(
|
||
width: IslandMetrics.panelSize.width,
|
||
height: IslandMetrics.panelSize.height,
|
||
alignment: .topLeading
|
||
)
|
||
.contentShape(shape)
|
||
.onTapGesture { onToggleExpand() }
|
||
}
|
||
|
||
// MARK: - 内容
|
||
|
||
/// 内容层。
|
||
///
|
||
/// 顶部那块让位用的 `Color.clear` 必须 `allowsHitTesting(false)` ——
|
||
/// `Color.clear` 是**参与命中测试**的,一块 640pt 宽的透明色压在外壳之上,
|
||
/// 会把那一带的点击全部吃掉,外壳的 `onTapGesture` 永远不触发。
|
||
private var contentLayer: some View {
|
||
VStack(spacing: 0) {
|
||
Color.clear
|
||
.frame(height: contentTopInset)
|
||
.allowsHitTesting(false)
|
||
|
||
content
|
||
.frame(width: metrics.bodyWidth, height: metrics.bodyHeight)
|
||
.background(debugTint)
|
||
// 裁到岛主体的形状(下沿有圆角),而不是一个方框 ——
|
||
// 方框会让内容在圆角处露到玻璃外面。
|
||
.clipShape(
|
||
.rect(
|
||
bottomLeadingRadius: metrics.bottomRadius,
|
||
bottomTrailingRadius: metrics.bottomRadius,
|
||
style: .continuous
|
||
)
|
||
)
|
||
// 形态一换就整块换掉,配合下面的 transition 做淡入淡出。
|
||
.id(state)
|
||
.transition(.opacity)
|
||
|
||
Spacer(minLength: 0)
|
||
.allowsHitTesting(false)
|
||
}
|
||
.frame(
|
||
width: IslandMetrics.panelSize.width,
|
||
height: IslandMetrics.panelSize.height,
|
||
alignment: .top
|
||
)
|
||
// 内容比形状晚 80ms 出现(见 IslandAnimation.contentIn)。
|
||
//
|
||
// 这个 .animation 会**覆盖**外层 withAnimation 传下来的事务动画,
|
||
// 从而让形状和内容用两条不同的时间曲线 —— 形状先到位、内容再浮现。
|
||
// 这是 Dynamic Island 手感的核心,去掉它整个动效会显得廉价。
|
||
.animation(IslandAnimation.contentIn(reduceMotion: reduceMotion), value: state)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View {
|
||
switch state {
|
||
case .rest:
|
||
RestContent(store: store, stalled: model.stalled)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
// 跑马灯正在报某件事时,点它就该直达那件事 ——
|
||
// 跳到通用的会话列表等于让用户自己再找一遍刚看到的东西。
|
||
if let top = model.stalled.first {
|
||
onPickStalled(top)
|
||
} else {
|
||
onToggleExpand()
|
||
}
|
||
}
|
||
case .hover:
|
||
HoverContent(store: store, model: model, onFocus: onFocus)
|
||
case .expanded:
|
||
ExpandedContent(
|
||
store: store,
|
||
projects: projects,
|
||
closedSessions: closedSessions,
|
||
model: model,
|
||
onSelect: onSelect,
|
||
onCollapse: onCollapse,
|
||
onLaunch: onLaunch,
|
||
onSecretPath: onSecretPath,
|
||
onPickStalled: onPickStalled
|
||
)
|
||
case .intrusion:
|
||
if let intrusion = model.intrusion {
|
||
IntrusionContent(
|
||
event: intrusion,
|
||
session: store.sessions.first { $0.sessionId == intrusion.sessionId }
|
||
)
|
||
}
|
||
case .approval:
|
||
if let request = approvals.current {
|
||
ApprovalContent(
|
||
request: request,
|
||
queueCount: approvals.queue.count,
|
||
onAllow: { approvals.allow(request) },
|
||
onDeny: { approvals.deny(request) },
|
||
onSkip: { approvals.skip(request) }
|
||
)
|
||
}
|
||
case .nudge:
|
||
NudgeContent(
|
||
findings: model.nudge,
|
||
store: store,
|
||
model: model,
|
||
onPick: onPickStalled
|
||
)
|
||
case .answer:
|
||
if let request = model.answer {
|
||
AnswerContent(
|
||
request: request,
|
||
onSend: onReply,
|
||
onJump: {
|
||
if let session = store.sessions.first(
|
||
where: { $0.sessionId == request.sessionId }
|
||
) { onFocus(session) }
|
||
},
|
||
onDismiss: onCollapse,
|
||
feedback: model.answerFeedback,
|
||
onClearFeedback: { model.answerFeedback = nil }
|
||
)
|
||
}
|
||
case .prompt:
|
||
if let request = prompts.current {
|
||
AgentPromptContent(
|
||
request: request,
|
||
queueCount: prompts.queue.count,
|
||
onSubmitAnswers: { prompts.submitAnswers(request, answers: $0) },
|
||
onChoose: { prompts.choose(request, option: $0) },
|
||
// 计划确认框吃不下 hook 的显式 allow(见 pressDialogKey 注释),
|
||
// 批准 = 放行后替用户在终端框里按 1 / 2。驳回走 hook deny 有效。
|
||
onApprovePlanKey: { onDialogKey(request, .digit($0)) },
|
||
onRejectPlan: { prompts.rejectPlan(request) },
|
||
onApproveEnterPlan: { onDialogKey(request, .digit(1)) },
|
||
onRejectEnterPlan: { prompts.rejectEnterPlan(request) },
|
||
onAllowPermission: { onDialogKey(request, .digit(1)) },
|
||
onDenyPermission: { onDialogKey(request, .escape) },
|
||
onGoToTerminal: {
|
||
// 先放行(终端原生框接管),再跳过去。找不到会话就只放行 ——
|
||
// 用户至少还能自己切窗口,原生框已经在等他了。
|
||
prompts.passthrough(request)
|
||
if let session = store.sessions.first(
|
||
where: { $0.sessionId == request.sessionId }
|
||
) { onFocus(session) }
|
||
}
|
||
)
|
||
// 换卡必须重置多题表单的 @State(进度、已选答案),
|
||
// 否则上一张卡答到第 2 题,下一张卡会从第 2 题开始。
|
||
.id(request.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 闯入/审批时给外壳染上状态色,让人一眼知道这不是普通展开。
|
||
private var shellTint: Color? {
|
||
switch state {
|
||
case .intrusion:
|
||
return model.intrusion?.accent.opacity(0.26)
|
||
case .approval:
|
||
guard let risk = approvals.current?.risk else { return nil }
|
||
return risk == .irreversible
|
||
? IslandTheme.danger.opacity(0.28)
|
||
: IslandTheme.waiting.opacity(0.24)
|
||
case .nudge, .answer:
|
||
return StallPalette.color(for: model.topStallReason).opacity(0.24)
|
||
case .prompt:
|
||
return IslandTheme.waiting.opacity(0.24)
|
||
default:
|
||
// 折叠态:有东西卡着就整条下唇染色。
|
||
// 这一层不呼吸(呼吸交给 RestContent 里的那条),它只负责底色。
|
||
guard let reason = model.topStallReason else { return nil }
|
||
return StallPalette.color(for: reason).opacity(0.12)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 滞留原因 → 颜色。断线是异常,用危险色;其余用待办色。
|
||
enum StallPalette {
|
||
static func color(for reason: StallReason?) -> Color {
|
||
guard let reason else { return IslandTheme.waiting }
|
||
switch reason {
|
||
case .interrupted: return IslandTheme.danger
|
||
default: return IslandTheme.waiting
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 折叠态
|
||
|
||
/// 折叠常驻内容:会话数 + 状态点阵 + waiting 徽章。
|
||
///
|
||
/// 这里**不画像素小人** —— 14 pt 高的条里放 16×16 的像素画只会糊成一坨,
|
||
/// 而且 9 个小人持续动画既是视觉噪音也是无谓能耗。小人从 hover 才出场,
|
||
/// 这也让悬停有了"奖励感"。
|
||
struct RestContent: View {
|
||
let store: SessionStore
|
||
/// 当前卡着的会话(含已经不再主动闯入的)。有它就上跑马灯。
|
||
var stalled: [StallFinding] = []
|
||
|
||
var body: some View {
|
||
// 有东西卡着时,折叠态从"状态点阵"换成"滚动的原因"。
|
||
//
|
||
// 点阵回答的是"跑着几个",而卡住的时候用户要知道的是"哪个、为什么"。
|
||
// 点阵在这种时候是纯噪音 —— 它每一颗都长得一样。
|
||
//
|
||
// **只在有 stall 时才滚**,平时回到静态点阵,不引入常驻的动画耗电。
|
||
if stalled.isEmpty {
|
||
dots
|
||
} else {
|
||
NudgeTicker(stalled: stalled, store: store)
|
||
}
|
||
}
|
||
|
||
private var dots: some View {
|
||
HStack(spacing: 10) {
|
||
// 左侧的等宽隐形占位。
|
||
//
|
||
// 没有它的话,徽章一出现,「计数 + 状态点」这一组就会被整体挤向左 ——
|
||
// 整个 HStack 是居中的,但视觉重心(那排点)不在岛的对称轴上,
|
||
// 读起来就是"歪了"。用 `.hidden()` 而不是写死一个宽度:
|
||
// 徽章两位数时会变宽,隐形副本会跟着变,永远对得上。
|
||
if store.waitingCount > 0 {
|
||
WaitingBadge(count: store.waitingCount).hidden()
|
||
}
|
||
|
||
Text("\(store.sessions.count)")
|
||
.font(IslandTheme.label(10, .semibold))
|
||
.monospacedDigit()
|
||
.foregroundStyle(.white.opacity(0.78))
|
||
|
||
HStack(spacing: 4) {
|
||
ForEach(store.sessions.prefix(12)) { session in
|
||
StatusDot(status: session.status, size: 6)
|
||
}
|
||
}
|
||
|
||
if store.waitingCount > 0 {
|
||
// 折叠态**也**要能在余光里被看见。岛收起来的时候正是用户
|
||
// 注意力在别处的时候,一个静止的小徽章会被完全忽略。
|
||
WaitingBadge(count: store.waitingCount)
|
||
.transition(.scale(scale: 0.4).combined(with: .opacity))
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
|
||
/// 折叠态的跑马灯 —— 卡住的会话横向滚过下唇。
|
||
///
|
||
/// 用户原话:"提示虽然有个 1 但是我可能关注其他屏幕就没看到"。
|
||
/// 一个静止的小徽章在余光里等于不存在,会动的东西才会被眼角捕捉到。
|
||
///
|
||
/// 形态约束是硬的:下唇只有 14pt 高,字最大放到 9pt,所以文案必须极短
|
||
/// (`StallReason.shortLabel` 全是三个字:"断线了"/"在问你"/"待验收")。
|
||
struct NudgeTicker: View {
|
||
let stalled: [StallFinding]
|
||
let store: SessionStore
|
||
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
@State private var phase: CGFloat = 0
|
||
|
||
/// 滚动速度。pt/秒。慢到能读清,快到不会让人等。
|
||
private static let speed: CGFloat = 22
|
||
|
||
private var items: [(id: String, text: String, color: Color)] {
|
||
stalled.prefix(6).map { finding in
|
||
let name = store.sessions
|
||
.first { $0.sessionId == finding.sessionId }?
|
||
.name ?? finding.sessionId
|
||
return (
|
||
finding.sessionId,
|
||
"\(finding.reason.symbol) \(name) \(finding.reason.shortLabel)",
|
||
StallPalette.color(for: finding.reason)
|
||
)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
GeometryReader { proxy in
|
||
let content = strip
|
||
ZStack(alignment: .leading) {
|
||
if reduceMotion {
|
||
// 减弱动态效果下不滚,只显示最紧急的那一条。
|
||
// 信息不能丢,能丢的只是动效。
|
||
content.frame(maxWidth: proxy.size.width, alignment: .leading)
|
||
} else {
|
||
// 画两份首尾相接,位移到一份宽度时归零,得到无缝循环。
|
||
// 单份的话滚出去之后会有一段空白。
|
||
HStack(spacing: gap) {
|
||
content
|
||
content
|
||
}
|
||
.offset(x: -phase)
|
||
.onAppear { start(width: proxy.size.width) }
|
||
}
|
||
}
|
||
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .leading)
|
||
.clipped()
|
||
}
|
||
.padding(.horizontal, 14)
|
||
}
|
||
|
||
private var gap: CGFloat { 18 }
|
||
|
||
private var strip: some View {
|
||
HStack(spacing: gap) {
|
||
ForEach(items, id: \.id) { item in
|
||
Text(item.text)
|
||
.font(IslandTheme.label(9, .semibold))
|
||
.foregroundStyle(item.color)
|
||
.fixedSize()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func start(width: CGFloat) {
|
||
// 估个宽度就够:滚动是循环的,估偏一点只影响循环周期,不影响观感。
|
||
let estimated = items.reduce(CGFloat(0)) { $0 + CGFloat($1.text.count) * 9 + gap }
|
||
guard estimated > 0 else { return }
|
||
let duration = Double(estimated / Self.speed)
|
||
withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) {
|
||
phase = estimated
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 折叠态的待办徽章。带呼吸光晕。
|
||
struct WaitingBadge: View {
|
||
let count: Int
|
||
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
@State private var breathing = false
|
||
|
||
var body: some View {
|
||
Text("\(count)")
|
||
.font(IslandTheme.label(9, .bold))
|
||
.monospacedDigit()
|
||
.foregroundStyle(.black)
|
||
.padding(.horizontal, 5)
|
||
.frame(height: 12)
|
||
.background(IslandTheme.waiting, in: .capsule)
|
||
.shadow(
|
||
color: IslandTheme.waiting.opacity(breathing ? 0.85 : 0.25),
|
||
radius: breathing ? 5 : 2
|
||
)
|
||
.onAppear {
|
||
guard !reduceMotion else { return }
|
||
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
|
||
breathing = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 悬停态
|
||
|
||
/// 悬停态:一排像素小人,指到谁谁抬起来并显示名字,点一下展开它的详情。
|
||
///
|
||
/// 全部小人画在**同一个 Canvas**(`SpriteRow`)里,交互靠上面叠的一层透明命中格 ——
|
||
/// 这样既保住了"单 Canvas 单时钟"的能耗设计,又拿到了逐个小人的悬停和点击。
|
||
/// 给每个小人套一个独立视图的话就是 N 个 Canvas + N 个 TimelineView。
|
||
struct HoverContent: View {
|
||
let store: SessionStore
|
||
let model: IslandModel
|
||
let onFocus: (AgentSession) -> Void
|
||
|
||
private var sessions: [AgentSession] {
|
||
Array(store.sessions.prefix(IslandMetrics.hoverSpriteLimit))
|
||
}
|
||
|
||
private var hovered: AgentSession? {
|
||
sessions.first { $0.sessionId == model.hoveredSessionId }
|
||
}
|
||
|
||
private var spriteSide: CGFloat { IslandMetrics.hoverSpriteSide }
|
||
private var spacing: CGFloat { IslandMetrics.hoverSpriteSpacing }
|
||
/// 命中格的高度 = 小人 + 抬升余量 + 状态条那一块,整格都可点。
|
||
private var cellHeight: CGFloat {
|
||
spriteSide + IslandMetrics.hoverLift + IslandMetrics.hoverStatusBarBlock
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: IslandMetrics.hoverGap) {
|
||
SpriteScreen { screen }
|
||
caption
|
||
}
|
||
.padding(.vertical, IslandMetrics.hoverVerticalInset)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
// 点小人之间的空隙、点摘要文字,同样要能展开 —— 否则 380pt 宽的岛
|
||
// 只有四个 32pt 的小方块可点,其余全是死区。
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
onFocus(hovered ?? store.mostUrgent ?? sessions.first ?? placeholder)
|
||
}
|
||
}
|
||
|
||
/// 一个会话都没有时的兜底,避免 `onTapGesture` 里出现可选解包。
|
||
private var placeholder: AgentSession {
|
||
AgentSession(
|
||
sessionId: "", pid: 0, cwd: NSHomeDirectory(), name: nil,
|
||
status: .unknown, rawStatus: "", kind: .unknown
|
||
)
|
||
}
|
||
|
||
private var screen: some View {
|
||
ZStack(alignment: .topLeading) {
|
||
// ① 待办高亮。**必须和命中层分开。**
|
||
//
|
||
// 之前把它塞进命中格里,结果那格点不动了 —— 它带 shadow 和
|
||
// repeatForever 动画,会被合成成一个离屏层,把点击吞掉。
|
||
// 装饰归装饰、命中归命中,中间不要互相插手。
|
||
haloLayer
|
||
|
||
VStack(spacing: 0) {
|
||
SpriteRow(
|
||
sessions: sessions,
|
||
pixelSize: 2,
|
||
gridSize: 16,
|
||
spacing: spacing,
|
||
useMicroArt: false,
|
||
supportsLift: true,
|
||
liftedSessionId: model.hoveredSessionId
|
||
)
|
||
Spacer(minLength: 0)
|
||
// 每个小人下方一条状态色横条:比色点更能在 32pt 网格下形成节律。
|
||
HStack(spacing: spacing) {
|
||
ForEach(sessions) { session in
|
||
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
|
||
.fill(barColor(for: session))
|
||
.frame(width: barWidth(for: session), height: 3)
|
||
.frame(width: spriteSide)
|
||
}
|
||
}
|
||
}
|
||
.frame(height: cellHeight)
|
||
.allowsHitTesting(false)
|
||
|
||
hitGrid
|
||
}
|
||
.frame(
|
||
width: IslandMetrics.hoverRowWidth(sessionCount: store.sessions.count),
|
||
height: cellHeight
|
||
)
|
||
}
|
||
|
||
/// 待办高亮层。**纯装饰,完全不参与命中测试。**
|
||
///
|
||
/// 光有下方那条状态色横条是不够的 —— 用户的原话是
|
||
/// "通知来了有个要处理,没有高亮提醒,就不知道哪个需要我进行下一步操作"。
|
||
/// "该你了"必须在余光里就能被抓到,不能要求先逐个去认颜色。
|
||
private var haloLayer: some View {
|
||
HStack(spacing: spacing) {
|
||
ForEach(sessions) { session in
|
||
Group {
|
||
if session.status == .waiting {
|
||
WaitingHalo()
|
||
} else {
|
||
Color.clear
|
||
}
|
||
}
|
||
.frame(width: spriteSide, height: cellHeight)
|
||
}
|
||
}
|
||
.frame(height: cellHeight)
|
||
.allowsHitTesting(false)
|
||
}
|
||
|
||
/// 叠在最上面的命中格。每格对应一个小人。
|
||
///
|
||
/// 两条约束:
|
||
///
|
||
/// 1. **这一层只有 `Color.clear`**,不放任何绘制内容。带 shadow 或常驻动画的
|
||
/// 视图会被合成成离屏层,进而把点击吃掉 —— 待办高亮塞进来时就是这样,
|
||
/// 高亮的那一格反而点不动了。
|
||
/// 2. **格子之间不留缝。** 视觉上小人之间有 8pt 间距,但那 8pt 如果不属于
|
||
/// 任何一格就是死区;再加上高亮光晕本来就会向外溢出,
|
||
/// "看起来高亮的地方"有一圈根本不可点。所以命中格按 `小人宽 + 间距`
|
||
/// 连续平铺,间距归两侧各一半。
|
||
private var hitGrid: some View {
|
||
HStack(spacing: 0) {
|
||
ForEach(sessions) { session in
|
||
Color.clear
|
||
.frame(width: spriteSide + spacing, height: cellHeight)
|
||
.contentShape(Rectangle())
|
||
.onHover { inside in
|
||
if inside {
|
||
model.hoveredSessionId = session.sessionId
|
||
} else if model.hoveredSessionId == session.sessionId {
|
||
model.hoveredSessionId = nil
|
||
}
|
||
}
|
||
.onTapGesture { onFocus(session) }
|
||
.help(session.name ?? session.fallbackProjectName)
|
||
}
|
||
}
|
||
// 平铺后总宽比小人排多出一个 spacing,向两侧各退半格,保持同轴。
|
||
.padding(.horizontal, -spacing / 2)
|
||
}
|
||
|
||
private func barColor(for session: AgentSession) -> Color {
|
||
let base = IslandTheme.color(for: session.status)
|
||
if session.sessionId == model.hoveredSessionId { return base }
|
||
return base.opacity(session.status == .idle ? 0.35 : 0.85)
|
||
}
|
||
|
||
/// 被指到的那条横条变长,作为"选中"的第二编码通道(不只靠亮度)。
|
||
private func barWidth(for session: AgentSession) -> CGFloat {
|
||
session.sessionId == model.hoveredSessionId ? spriteSide - 4 : 16
|
||
}
|
||
|
||
// MARK: 名字行
|
||
|
||
@ViewBuilder
|
||
private var caption: some View {
|
||
if let hovered {
|
||
VStack(spacing: 1) {
|
||
Text(hovered.name ?? hovered.fallbackProjectName)
|
||
.font(IslandTheme.label(13, .semibold))
|
||
.foregroundStyle(.white)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
HStack(spacing: 5) {
|
||
StatusDot(status: hovered.status, size: 5)
|
||
Text(statusText(for: hovered))
|
||
.font(IslandTheme.label(11))
|
||
.foregroundStyle(IslandTheme.color(for: hovered.status).opacity(0.95))
|
||
Text("·")
|
||
.font(IslandTheme.label(11))
|
||
.foregroundStyle(.white.opacity(0.35))
|
||
Text(hovered.fallbackProjectName)
|
||
.font(IslandTheme.label(11))
|
||
.foregroundStyle(.white.opacity(0.62))
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.frame(height: IslandMetrics.hoverCaptionHeight)
|
||
.padding(.horizontal, 12)
|
||
} else if let urgent = store.mostUrgent {
|
||
// **有人在等你时,默认就把它的名字顶出来。**
|
||
//
|
||
// 之前这里只显示 "1 忙 · 1 待办 · 1 命令 · 1 闲" 这种汇总 ——
|
||
// 它回答了"有几个在等",却没回答"是哪一个",用户还是得逐个去指。
|
||
// 而"哪个需要我下一步操作"恰恰是打开这个岛最主要的原因。
|
||
VStack(spacing: 1) {
|
||
HStack(spacing: 5) {
|
||
Image(systemName: "exclamationmark.circle.fill")
|
||
.font(.system(size: 10))
|
||
Text(urgent.name ?? urgent.fallbackProjectName)
|
||
.font(IslandTheme.label(13, .semibold))
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
Text("在等你")
|
||
.font(IslandTheme.label(11))
|
||
.foregroundStyle(IslandTheme.waiting.opacity(0.75))
|
||
}
|
||
.foregroundStyle(IslandTheme.waiting)
|
||
|
||
Text(summary)
|
||
.font(IslandTheme.label(10))
|
||
.foregroundStyle(.white.opacity(0.45))
|
||
}
|
||
.frame(height: IslandMetrics.hoverCaptionHeight)
|
||
.padding(.horizontal, 12)
|
||
} else {
|
||
VStack(spacing: 1) {
|
||
Text(summary)
|
||
.font(IslandTheme.label(12, .medium))
|
||
.foregroundStyle(.white.opacity(0.82))
|
||
Text("指一个小人看它在干嘛,点一下展开")
|
||
.font(IslandTheme.label(10))
|
||
.foregroundStyle(.white.opacity(0.42))
|
||
}
|
||
.frame(height: IslandMetrics.hoverCaptionHeight)
|
||
}
|
||
}
|
||
|
||
private func statusText(for session: AgentSession) -> String {
|
||
switch session.status {
|
||
case .busy: return "工作中"
|
||
case .waiting: return session.waitingFor ?? "等你输入"
|
||
case .shell: return "跑命令"
|
||
case .idle: return "空闲"
|
||
case .unknown: return session.rawStatus
|
||
}
|
||
}
|
||
|
||
private var summary: String {
|
||
var parts: [String] = []
|
||
if store.busyCount > 0 { parts.append("\(store.busyCount) 忙") }
|
||
if store.waitingCount > 0 { parts.append("\(store.waitingCount) 待办") }
|
||
if store.shellCount > 0 { parts.append("\(store.shellCount) 命令") }
|
||
if store.idleCount > 0 { parts.append("\(store.idleCount) 闲") }
|
||
return parts.isEmpty ? "没有会话" : parts.joined(separator: " · ")
|
||
}
|
||
}
|
||
|
||
/// waiting 小人身后的呼吸光晕。
|
||
///
|
||
/// 这是"该你了"的主要视觉抓取手段。三条叠加:
|
||
/// 琥珀色是全套配色里最跳的、**只有它会动**、而且带外发光。
|
||
///
|
||
/// 呼吸周期给到 1.6 秒并且幅度克制 —— 快速闪烁在余光里是"警报",
|
||
/// 会让人焦虑;慢呼吸是"这里有件事",不打断当前的注意力。
|
||
struct WaitingHalo: View {
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
@State private var breathing = false
|
||
|
||
var body: some View {
|
||
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||
.fill(IslandTheme.waiting.opacity(breathing ? 0.26 : 0.13))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||
.stroke(IslandTheme.waiting.opacity(breathing ? 0.75 : 0.4), lineWidth: 1)
|
||
}
|
||
// 光晕控制在 4pt 以内:它要落在命中格里面。溢出去的那一圈
|
||
// 看着是高亮、点下去却没反应,比不高亮更让人困惑。
|
||
.shadow(color: IslandTheme.waiting.opacity(breathing ? 0.55 : 0.2), radius: 4)
|
||
.onAppear {
|
||
guard !reduceMotion else { return }
|
||
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) {
|
||
breathing = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 展开态
|
||
|
||
struct ExpandedContent: View {
|
||
let store: SessionStore
|
||
let projects: ProjectStore
|
||
let closedSessions: ClosedSessionStore
|
||
let model: IslandModel
|
||
let onSelect: (AgentSession) -> Void
|
||
let onCollapse: () -> Void
|
||
let onLaunch: (Project, LaunchMode) -> Void
|
||
var onSecretPath: ((Project) -> String?)?
|
||
var onPickStalled: (StallFinding) -> Void = { _ in }
|
||
|
||
private var selected: AgentSession? {
|
||
store.sessions.first { $0.sessionId == model.selectedSessionId }
|
||
?? store.mostUrgent
|
||
?? store.sessions.first
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
header
|
||
tabPicker
|
||
|
||
switch model.tab {
|
||
case .sessions: sessionsBody
|
||
case .projects: ProjectListContent(
|
||
projects: projects, store: store, closedSessions: closedSessions,
|
||
onJump: onSelect, onLaunch: onLaunch,
|
||
onSecretPath: onSecretPath
|
||
)
|
||
}
|
||
}
|
||
// 终端定位要跑 AppleScript、git 要跑子进程,只在真的展开时解析(内部有节流)。
|
||
.onAppear {
|
||
store.resolveTerminalLabels()
|
||
store.resolveWorkspaceGit(for: store.sessions.map(\.cwd))
|
||
}
|
||
}
|
||
|
||
/// 会话 / 项目 切换。
|
||
///
|
||
/// 不用系统的 `.segmented` Picker:那个控件在非活跃窗口里会整体去饱和,
|
||
/// 和岛上其它按钮是同一个毛病(见 `IslandButtonStyle`)。
|
||
private var tabPicker: some View {
|
||
HStack(spacing: 4) {
|
||
ForEach(IslandTab.allCases) { tab in
|
||
let selected = model.tab == tab
|
||
Button {
|
||
withAnimation(.snappy(duration: 0.18)) { model.tab = tab }
|
||
} label: {
|
||
HStack(spacing: 5) {
|
||
Text(tab.rawValue)
|
||
Text(tab == .sessions
|
||
? "\(store.sessions.count)"
|
||
: "\(projects.projects.count)")
|
||
.monospacedDigit()
|
||
.foregroundStyle(.white.opacity(selected ? 0.55 : 0.35))
|
||
}
|
||
.font(IslandTheme.label(12, .semibold))
|
||
.foregroundStyle(.white.opacity(selected ? 0.95 : 0.5))
|
||
.padding(.horizontal, 10)
|
||
.frame(height: 24)
|
||
.background(
|
||
.white.opacity(selected ? 0.14 : 0),
|
||
in: .rect(cornerRadius: 7, style: .continuous)
|
||
)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.bottom, 6)
|
||
}
|
||
|
||
private var sessionsBody: some View {
|
||
VStack(spacing: 0) {
|
||
if let selected {
|
||
SessionDetailCard(
|
||
session: selected,
|
||
workspace: store.workspaceGit[selected.cwd],
|
||
terminalLabel: store.terminalLabels[selected.sessionId],
|
||
jumpOutcome: store.lastJump?.sessionId == selected.sessionId
|
||
? store.lastJump?.outcome : nil,
|
||
stall: model.stalled.first { $0.sessionId == selected.sessionId },
|
||
onJump: { onSelect(selected) },
|
||
onAnswer: {
|
||
guard let finding = model.stalled.first(
|
||
where: { $0.sessionId == selected.sessionId }
|
||
) else { return }
|
||
onPickStalled(finding)
|
||
}
|
||
)
|
||
.padding(.horizontal, 12)
|
||
.padding(.bottom, 8)
|
||
}
|
||
|
||
Divider().opacity(0.22)
|
||
|
||
ScrollView {
|
||
LazyVStack(spacing: 3) {
|
||
ForEach(store.sessions) { session in
|
||
SessionRowView(
|
||
session: session,
|
||
branch: store.workspaceGit[session.cwd]?.info.branch,
|
||
isSelected: session.sessionId == selected?.sessionId,
|
||
stall: model.stalled
|
||
.first { $0.sessionId == session.sessionId }?.reason,
|
||
// 点行 = 看详情。点行内的 ⇱ 按钮 = 直接跳转。
|
||
// 两个动作各有入口,不用"先选中再点第二下"。
|
||
onSelect: { model.selectedSessionId = session.sessionId },
|
||
onJump: { onSelect(session) }
|
||
)
|
||
}
|
||
}
|
||
.padding(.vertical, 6)
|
||
.padding(.horizontal, 10)
|
||
}
|
||
.scrollIndicators(.never)
|
||
}
|
||
}
|
||
|
||
private var header: some View {
|
||
HStack(spacing: 8) {
|
||
Text("Vibe Foreman")
|
||
.font(IslandTheme.label(13, .semibold))
|
||
.foregroundStyle(.white)
|
||
Text("\(store.sessions.count) 会话")
|
||
.font(IslandTheme.label(11))
|
||
.foregroundStyle(.white.opacity(0.5))
|
||
Spacer()
|
||
if store.waitingCount > 0 {
|
||
Label("\(store.waitingCount)", systemImage: "exclamationmark.circle.fill")
|
||
.font(IslandTheme.label(11, .semibold))
|
||
.foregroundStyle(IslandTheme.waiting)
|
||
}
|
||
Button(action: onCollapse) {
|
||
Image(systemName: "chevron.up")
|
||
.font(.system(size: 10, weight: .bold))
|
||
.foregroundStyle(.white.opacity(0.6))
|
||
.frame(width: 22, height: 22)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("收起")
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.frame(height: IslandMetrics.expandedHeaderHeight)
|
||
}
|
||
}
|
||
|
||
// MARK: - 项目栏
|
||
|
||
/// 岛上的项目列表。
|
||
///
|
||
/// 存在的理由不是"也能浏览项目",而是**把「开发 xxx」这个动作收进岛里**。
|
||
/// 在这之前,想开一个还没在跑的项目必须:点菜单栏 → 开主窗口 → 找到项目 → 启动。
|
||
/// 而这是这个工具最高频的动作。
|
||
///
|
||
/// 排序:**置顶 > 有会话在跑 > 名字**(口径见 ProjectOrdering.islandOrder)。
|
||
/// 不做搜索框:岛的 panel 基本不是 key window,文本框拿不到键盘焦点,
|
||
/// 放一个点不动的搜索框比没有更糟。要搜索去主窗口。
|
||
struct ProjectListContent: View {
|
||
let projects: ProjectStore
|
||
let store: SessionStore
|
||
let closedSessions: ClosedSessionStore
|
||
let onJump: (AgentSession) -> Void
|
||
let onLaunch: (Project, LaunchMode) -> Void
|
||
var onSecretPath: ((Project) -> String?)?
|
||
|
||
/// 这个项目下正在跑的会话(cwd 落在项目目录里就算)。
|
||
private func sessions(of project: Project) -> [AgentSession] {
|
||
let root = project.expandedPath
|
||
return store.sessions.filter { $0.cwd == root || $0.cwd.hasPrefix(root + "/") }
|
||
}
|
||
|
||
/// 没绑定共用密钥就返回 nil,菜单里那一项也就不出现 —— 和主窗口的条件一致。
|
||
private func secretPathAction(for project: Project) -> (() -> Void)? {
|
||
guard let path = onSecretPath?(project) else { return nil }
|
||
return { Clipboard.copy(path) }
|
||
}
|
||
|
||
private var ordered: [Project] {
|
||
ProjectOrdering.islandOrder(
|
||
projects.projects,
|
||
pinned: projects.pinned,
|
||
runningCount: { sessions(of: $0).count },
|
||
lastCommitAt: { projects.git(for: $0)?.lastCommitAt }
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
Group {
|
||
if projects.projects.isEmpty {
|
||
emptyState
|
||
} else {
|
||
ScrollView {
|
||
LazyVStack(spacing: 3) {
|
||
ForEach(ordered) { project in
|
||
ProjectRowView(
|
||
project: project,
|
||
running: sessions(of: project),
|
||
git: projects.git(for: project),
|
||
isPinned: projects.isPinned(project),
|
||
resumable: closedSessions.latest(
|
||
forProject: project.expandedPath
|
||
),
|
||
isMissing: projects.isMissing(project),
|
||
onOpen: { open(project) },
|
||
onLaunch: { onLaunch(project, $0) },
|
||
onTogglePin: { projects.togglePin(project) },
|
||
onRemove: { projects.remove([project]) },
|
||
onCopySecretPath: secretPathAction(for: project)
|
||
)
|
||
}
|
||
}
|
||
.padding(.vertical, 6)
|
||
.padding(.horizontal, 10)
|
||
}
|
||
.scrollIndicators(.never)
|
||
}
|
||
}
|
||
.onAppear { projects.refreshGit() }
|
||
}
|
||
|
||
/// 点一行的默认动作:
|
||
/// **有会话在跑就跳过去,没有就直接起一个。**
|
||
/// 这两件事用户想要的其实是同一个 —— "让我到这个项目上去"。
|
||
private func open(_ project: Project) {
|
||
if let session = sessions(of: project).min(by: { $0.status < $1.status }) {
|
||
onJump(session)
|
||
} else {
|
||
onLaunch(project, .claude)
|
||
}
|
||
}
|
||
|
||
private var emptyState: some View {
|
||
VStack(spacing: 6) {
|
||
Text("没有读到项目")
|
||
.font(IslandTheme.label(13, .semibold))
|
||
.foregroundStyle(IslandTheme.waiting)
|
||
Text(projects.loadDiagnostic ?? "projects.yaml 里没有条目")
|
||
.font(IslandTheme.label(11))
|
||
.foregroundStyle(.white.opacity(0.55))
|
||
if let path = projects.sourceURL?.path {
|
||
Text(path)
|
||
.font(IslandTheme.mono(9))
|
||
.foregroundStyle(.white.opacity(0.35))
|
||
.lineLimit(1)
|
||
.truncationMode(.head)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.padding(.horizontal, 20)
|
||
}
|
||
}
|
||
|
||
/// 项目列表的一行。
|
||
///
|
||
/// 点击行为分两种:
|
||
/// - 有会话在跑:点整行跳过去(这是最高频的动作,不能多一步)。
|
||
/// - 没有会话:点整行**弹启动菜单**而不是直接起 claude ——
|
||
/// "点一下就启动了一个 Claude Code" 的误触成本太高。
|
||
struct ProjectRowView: View {
|
||
let project: Project
|
||
let running: [AgentSession]
|
||
let git: GitInfo?
|
||
let isPinned: Bool
|
||
/// 上一个被「关窗即结束」收掉、且接得回来的会话。
|
||
/// 有它就说明这个项目"关掉了但没干完",菜单第一项应该是接回去而不是重开。
|
||
var resumable: ClosedSession?
|
||
/// 目录已经不在了。岛上的行不显示路径,所以这一档**必须有视觉标记** ——
|
||
/// 否则一个指向已删除目录的项目看起来和正常的一模一样。
|
||
var isMissing: Bool = false
|
||
let onOpen: () -> Void
|
||
let onLaunch: (LaunchMode) -> Void
|
||
let onTogglePin: () -> Void
|
||
var onRemove: (() -> Void)?
|
||
var onCopySecretPath: (() -> Void)?
|
||
|
||
@State private var isHovered = false
|
||
|
||
/// 这个项目当前"最需要注意"的状态:拿在跑的会话里优先级最高的那个。
|
||
private var status: SessionStatus? {
|
||
running.min(by: { $0.status < $1.status })?.status
|
||
}
|
||
|
||
var body: some View {
|
||
rowBody
|
||
.contentShape(Rectangle())
|
||
// 右键盖在整行上。只认右键和 ctrl+左键,左键照旧穿透下去 ——
|
||
// 见 RightClickCatcher 里 hitTest 的说明。
|
||
.overlay(RightClickCatcher { presentLaunchMenu() })
|
||
.onTapGesture {
|
||
// 有会话:跳过去。无会话:在鼠标位置弹原生启动菜单 ——
|
||
// SwiftUI Menu 的两种整行方案都在 macOS 上翻过车
|
||
// (label 被压扁 / 透明盖层收不到点击),见 RowMenu 注释。
|
||
// 目录没了就别去开 —— tmux 会静默开在家目录。弹菜单让人移除。
|
||
if running.isEmpty || isMissing {
|
||
presentLaunchMenu()
|
||
} else {
|
||
onOpen()
|
||
}
|
||
}
|
||
.onHover { isHovered = $0 }
|
||
.help(project.expandedPath)
|
||
}
|
||
|
||
/// 启动菜单。整行点击(无会话时)、右键、"..." 三个入口共用,
|
||
/// 内容也和主窗口共用同一份(`ProjectMenu`)。
|
||
private func presentLaunchMenu() {
|
||
RowMenu.present(
|
||
ProjectMenu.items(
|
||
isPinned: isPinned, resumable: resumable, isMissing: isMissing,
|
||
onLaunch: onLaunch, onTogglePin: onTogglePin, onRemove: onRemove,
|
||
onCopySecretPath: onCopySecretPath
|
||
)
|
||
)
|
||
}
|
||
|
||
private var rowBody: some View {
|
||
HStack(spacing: 0) {
|
||
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||
.fill(status.map(IslandTheme.color(for:)) ?? .white.opacity(0.14))
|
||
.frame(width: 2, height: 28)
|
||
|
||
// 有会话在跑就把那个人画出来 —— 一眼看出"这个项目现在有人在忙"。
|
||
Group {
|
||
if let session = running.min(by: { $0.status < $1.status }) {
|
||
PixelSprite(session: session, pixelSize: 2, gridSize: 8, useMicroArt: true)
|
||
} else {
|
||
Image(systemName: "folder")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.white.opacity(0.3))
|
||
.frame(width: 16, height: 16)
|
||
}
|
||
}
|
||
.padding(.leading, 8)
|
||
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
HStack(spacing: 6) {
|
||
Text(project.name)
|
||
.font(IslandTheme.label(13, .semibold))
|
||
.foregroundStyle(.white.opacity(running.isEmpty ? 0.78 : 1))
|
||
.lineLimit(1)
|
||
if isPinned {
|
||
Image(systemName: "pin.fill")
|
||
.font(.system(size: 8))
|
||
.foregroundStyle(.white.opacity(0.5))
|
||
.rotationEffect(.degrees(45))
|
||
}
|
||
if isMissing {
|
||
Text("目录已不存在")
|
||
.font(IslandTheme.label(10, .medium))
|
||
.foregroundStyle(IslandTheme.waiting)
|
||
} else if running.count > 0 {
|
||
Text("\(running.count) 个会话")
|
||
.font(IslandTheme.label(10, .medium))
|
||
.foregroundStyle(status.map(IslandTheme.color(for:)) ?? .white)
|
||
}
|
||
}
|
||
HStack(spacing: 5) {
|
||
if let branch = git?.branch {
|
||
Text(branch)
|
||
.font(IslandTheme.mono(10))
|
||
.foregroundStyle(.white.opacity(0.45))
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
}
|
||
if let changes = git?.changeCount, changes > 0 {
|
||
Text("\(changes)△")
|
||
.font(IslandTheme.mono(10, .semibold))
|
||
.foregroundStyle(IslandTheme.waiting.opacity(0.8))
|
||
}
|
||
}
|
||
}
|
||
.padding(.leading, 10)
|
||
|
||
Spacer(minLength: 8)
|
||
|
||
// "..." = 启动菜单(继续上次、跳过权限、置顶等)。
|
||
// 有会话的行点整行是跳会话,这里是"换一种开法"的入口;
|
||
// 无会话的行点整行和点这里等价。
|
||
Button {
|
||
presentLaunchMenu()
|
||
} label: {
|
||
ellipsisIcon
|
||
}
|
||
.buttonStyle(.plain)
|
||
.opacity(isHovered ? 1 : 0.3)
|
||
.padding(.trailing, 8)
|
||
}
|
||
.frame(height: IslandMetrics.expandedProjectRowHeight)
|
||
.background {
|
||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||
.fill(.white.opacity(isHovered ? 0.08 : 0.028))
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
|
||
private var ellipsisIcon: some View {
|
||
Image(systemName: "ellipsis")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(.white.opacity(0.7))
|
||
.frame(width: 26, height: 22)
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
|
||
/// 展开态的一行。
|
||
///
|
||
/// 两个交互目标:
|
||
/// 1. **整行都可点**,不是只有文字可点。
|
||
/// 2. 右侧常驻快捷按钮 —— 常见操作(跳转、Finder)不该要求"先选中再点第二下"。
|
||
struct SessionRowView: View {
|
||
let session: AgentSession
|
||
var branch: String?
|
||
var isSelected = false
|
||
/// 这一行卡住了吗。卡住的行要在列表里就能被认出来 ——
|
||
/// 否则用户得逐个点开才知道哪个在等他,而列表存在的意义正是"扫一眼"。
|
||
var stall: StallReason?
|
||
var onSelect: () -> Void = {}
|
||
var onJump: () -> Void = {}
|
||
|
||
@State private var isHovered = false
|
||
|
||
var body: some View {
|
||
content
|
||
// 背景**必须常驻**(哪怕近乎透明)。
|
||
//
|
||
// 踩过的坑:以前只有 selected / waiting 的行才有背景,
|
||
// 结果普通行的空白区域没有任何可命中的内容,用户只能点到文字上 ——
|
||
// 一行 580pt 宽,真正能点的只有左边那一小截文字。
|
||
.background(rowBackground)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(perform: onSelect)
|
||
.onHover { isHovered = $0 }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var rowBackground: some View {
|
||
let shape = RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||
ZStack {
|
||
if isSelected {
|
||
shape.fill(.white.opacity(0.12))
|
||
shape.stroke(.white.opacity(0.18), lineWidth: 1)
|
||
} else if session.status == .waiting {
|
||
// waiting 行常驻高亮,扫一眼就能定位。
|
||
shape.fill(IslandTheme.waiting.opacity(isHovered ? 0.16 : 0.10))
|
||
} else {
|
||
shape.fill(.white.opacity(isHovered ? 0.08 : 0.028))
|
||
}
|
||
}
|
||
}
|
||
|
||
private var content: some View {
|
||
HStack(spacing: 0) {
|
||
// 左边条编码 kind:interactive 实线,bg 半透明 —— 比塞一个图标省地方。
|
||
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||
.fill(IslandTheme.color(for: session.status)
|
||
.opacity(session.kind == .bg ? 0.4 : 1.0))
|
||
.frame(width: 2, height: 36)
|
||
|
||
// 列表行用 8×8 的简化剪影(Micro art),不是把 16×16 缩小 ——
|
||
// 那样眼睛、手指这些 1px 细节会退化成噪点。
|
||
PixelSprite(session: session, pixelSize: 2, gridSize: 8, useMicroArt: true)
|
||
.padding(.leading, 8)
|
||
|
||
StatusDot(status: session.status, size: 6)
|
||
.padding(.leading, 6)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(session.name ?? session.sessionId)
|
||
.font(IslandTheme.label(13, .semibold))
|
||
.foregroundStyle(.white)
|
||
.lineLimit(1)
|
||
// 会话名截中间:`o3-ui-memory-idempotent-fixes` 这种
|
||
// 名字截尾就完全认不出是哪个了。
|
||
.truncationMode(.middle)
|
||
|
||
HStack(spacing: 5) {
|
||
Text(session.fallbackProjectName)
|
||
.font(IslandTheme.label(11))
|
||
.foregroundStyle(.white.opacity(0.62))
|
||
.lineLimit(1)
|
||
// 分支要在**列表里**就看得到,不能只在详情卡里。
|
||
// 同一个项目多开时,行与行之间的唯一区别往往就是分支。
|
||
if let branch {
|
||
Text(branch)
|
||
.font(IslandTheme.mono(10))
|
||
.foregroundStyle(.white.opacity(0.42))
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
}
|
||
}
|
||
}
|
||
.padding(.leading, 10)
|
||
|
||
Spacer(minLength: 8)
|
||
|
||
// 滞留徽章优先于 waitingFor:前者信息更具体
|
||
// ("在问你" / "断线了" vs 笼统的 "input needed"),
|
||
// 而且两个都显示会把行挤爆。
|
||
if let stall {
|
||
HStack(spacing: 3) {
|
||
Text(stall.symbol).font(.system(size: 9))
|
||
Text(stall.shortLabel)
|
||
.font(IslandTheme.label(10, .bold))
|
||
}
|
||
.foregroundStyle(StallPalette.color(for: stall))
|
||
.padding(.horizontal, 6)
|
||
.frame(height: 17)
|
||
.background(
|
||
Capsule().fill(StallPalette.color(for: stall).opacity(0.16))
|
||
)
|
||
.padding(.trailing, 8)
|
||
} else if let waitingFor = session.waitingFor {
|
||
Text(waitingFor)
|
||
.font(IslandTheme.label(10, .semibold))
|
||
.foregroundStyle(IslandTheme.waiting)
|
||
.lineLimit(1)
|
||
.padding(.trailing, 8)
|
||
}
|
||
|
||
Text(RelativeTime.short(from: session.updatedAt))
|
||
.font(IslandTheme.label(11))
|
||
.monospacedDigit()
|
||
.foregroundStyle(.white.opacity(0.42))
|
||
.frame(width: 30, alignment: .trailing)
|
||
|
||
quickActions
|
||
}
|
||
.frame(height: IslandMetrics.expandedRowHeight)
|
||
}
|
||
|
||
/// 行内快捷按钮。
|
||
///
|
||
/// 常驻占位(不是 hover 才出现):宽度恒定,行不会因为指针经过而重排;
|
||
/// 未悬停时压低透明度,不跟会话名抢注意力。
|
||
private var quickActions: some View {
|
||
HStack(spacing: 4) {
|
||
rowButton("arrow.up.forward.app", help: "跳到这个会话的终端", action: onJump)
|
||
rowButton("folder", help: "在 Finder 中打开") {
|
||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: session.cwd)
|
||
}
|
||
}
|
||
.opacity(isHovered || isSelected ? 1 : 0.34)
|
||
.padding(.leading, 8)
|
||
.padding(.trailing, 10)
|
||
}
|
||
|
||
private func rowButton(
|
||
_ symbol: String, help: String, action: @escaping () -> Void
|
||
) -> some View {
|
||
Button(action: action) {
|
||
Image(systemName: symbol)
|
||
.font(.system(size: 10, weight: .medium))
|
||
}
|
||
.buttonStyle(IslandButtonStyle(emphasis: .secondary, width: 26, height: 22))
|
||
.help(help)
|
||
}
|
||
}
|
||
|
||
/// 相对时间。岛和主窗口共用,避免两处各写一份导致格式漂移。
|
||
public enum RelativeTime {
|
||
public static func short(from date: Date?, now: Date = Date()) -> String {
|
||
guard let date else { return "—" }
|
||
let seconds = Int(now.timeIntervalSince(date))
|
||
if seconds < 60 { return "\(max(0, seconds))s" }
|
||
if seconds < 3600 { return "\(seconds / 60)m" }
|
||
if seconds < 86400 { return "\(seconds / 3600)h" }
|
||
return "\(seconds / 86400)d"
|
||
}
|
||
|
||
/// 已运行时长,用于详情卡。
|
||
public static func duration(since date: Date?, now: Date = Date()) -> String {
|
||
guard let date else { return "—" }
|
||
let total = max(0, Int(now.timeIntervalSince(date)))
|
||
let hours = total / 3600
|
||
let minutes = (total % 3600) / 60
|
||
if hours > 0 { return "\(hours)h \(minutes)m" }
|
||
if minutes > 0 { return "\(minutes)m" }
|
||
return "\(total)s"
|
||
}
|
||
}
|