import APIClient import Foundation // C2 · git 面板的**纯呈现规则**(零 I/O、零 SwiftUI)—— 把 w6 那条"只有一种 // 状态可以显示为绿色"的设计铁律做成可单测的数据,而不是散落在视图里的条件。 // // 真源:`docs/plans/w6-project-git-panel.md`(Design rule that drives everything) // + web 参照实现 `public/projects.ts:615-745 makeSyncBand`。iOS 与 web 必须给出 // 同一判断,只有布局不同(手机上四列带换成竖排卡片)。 // MARK: - 同步带(w6/G3) /// 项目/worktree 的上游同步态,已按"可信度"归约完毕。 /// /// 为什么每个字段都是 optional 而不是 0:`ahead`/`behind` 是对 `@{u}` 的比较, /// 而 `@{u}` 是**本地缓存**的远端引用,只有 fetch 会推动它。所以 /// - `ahead` 只用本地引用 ⇒ 永远可信; /// - `behind` 的新鲜度**不超过** `lastFetchMs` ⇒ 过期时必须标"未核实"; /// - `upstream == nil` 意思是"无可比对",**不是**"没有要推的东西"; /// - `nil` 计数是"算不出来",用 `?? 0` 折叠它就会让一个读取失败的仓库显示绿色 —— /// 这正是本类型存在的原因。 struct GitSyncBand: Equatable { /// HEAD 的三种处境(互斥)。 enum Head: Equatable { /// 分离 HEAD:没有分支,也就没有 ahead/behind。 case detached /// 分支不跟踪任何上游(新建 worktree 分支的常态)。 case noUpstream /// 正常跟踪。计数为 nil = 读不到(渲染 `—`,绝不当 0)。 case tracking(upstream: String, ahead: Int?, behind: Int?) } /// 工作区脏度三态。`unknown` 是主机关掉了 dirty 检查(`PROJECT_DIRTY_CHECK=0`), /// **不等于** clean —— 把它渲染成"干净"就是在替主机说谎。 enum Dirty: Equatable { case unknown case clean case changed(Int) } /// `FETCH_HEAD` 可以有多旧,`behind` 才不再可信(镜像 web `FETCH_STALE_MS`, /// `public/projects.ts:615`)。 static let fetchStaleMs: Double = 60 * 60 * 1000 let head: Head let dirty: Dirty /// 唯一允许显示为绿色的状态:有上游 && ↑0 && ↓0 && fetch 新鲜。 let isInSync: Bool /// `behind` 是否经过一次新鲜 fetch 核实过(false ⇒ 必须标"未核实")。 let isBehindVerified: Bool /// 分离 HEAD 上没有可 fetch 的目标(服务器会 400)——按钮直接禁用。 let canFetch: Bool /// nil = 非 git 目录(没有任何可如实陈述的内容)。 static func make(sync: SyncState?, dirtyCount: Int?, nowMs: Double) -> GitSyncBand? { guard let sync else { return nil } let isStale = Self.isStale(lastFetchMs: sync.lastFetchMs, nowMs: nowMs) let head = Self.head(for: sync) let isTracking: Bool if case .tracking = head { isTracking = true } else { isTracking = false } return GitSyncBand( head: head, dirty: Self.dirty(for: dirtyCount), isInSync: isTracking && sync.ahead == 0 && sync.behind == 0 && !isStale, isBehindVerified: isTracking && !isStale && sync.behind != nil, canFetch: sync.detached != true ) } /// 从未 fetch(nil)也算过期 —— "没 fetch 过"比"很久没 fetch"更不可信。 private static func isStale(lastFetchMs: Double?, nowMs: Double) -> Bool { guard let lastFetchMs else { return true } return nowMs - lastFetchMs > fetchStaleMs } private static func head(for sync: SyncState) -> Head { if sync.detached == true { return .detached } guard let upstream = sync.upstream else { return .noUpstream } return .tracking(upstream: upstream, ahead: sync.ahead, behind: sync.behind) } private static func dirty(for dirtyCount: Int?) -> Dirty { guard let dirtyCount else { return .unknown } return dirtyCount > 0 ? .changed(dirtyCount) : .clean } } // MARK: - 未推送边界(w6/G4) /// `git log` 列表里"已推送/未推送"的分界线位置。 /// /// 标记本身由服务器算(`unpushed`,按 SHA 前缀匹配 `@{u}..HEAD`)——客户端只决定 /// 画在哪一行之后,且**只画一次**。日期序会把一个未推送的 merge 排到已推送提交 /// 下面,所以边界必须取"最后一个 unpushed"的位置,而不是"前 N 行"。 enum GitLogBoundary { /// 边界画在返回索引所指行的**下方**;nil = 不画(无上游 ⇒ 无可比对; /// 或者一条未推送都没有)。 static func index(commits: [CommitLogEntry], upstream: String?) -> Int? { guard upstream != nil else { return nil } // `unpushed` 只在严格 true 时生效:nil 是"服务器没说",不是 false。 return commits.lastIndex { $0.unpushed == true } } } // MARK: - 相对时间 /// 提交时间/最近 fetch 的相对文案(中文)。服务器时间戳可能因主机时钟漂移落在 /// 未来 —— 那时退化为「刚刚」,绝不输出负数。 enum GitTimeFormat { private static let msPerSecond: Double = 1_000 private static let secondsPerMinute: Double = 60 private static let secondsPerHour: Double = 60 * 60 private static let secondsPerDay: Double = 24 * 60 * 60 /// 一分钟以内一律「刚刚」。 private static let justNowSeconds: Double = 60 static func relative(fromMs: Double, nowMs: Double) -> String { let seconds = max(0, (nowMs - fromMs) / msPerSecond) if seconds < justNowSeconds { return Copy.justNow } if seconds < secondsPerHour { return Copy.minutesAgo(Int(seconds / secondsPerMinute)) } if seconds < secondsPerDay { return Copy.hoursAgo(Int(seconds / secondsPerHour)) } return Copy.daysAgo(Int(seconds / secondsPerDay)) } private enum Copy { static let justNow = "刚刚" static func minutesAgo(_ value: Int) -> String { "\(value) 分钟前" } static func hoursAgo(_ value: Int) -> String { "\(value) 小时前" } static func daysAgo(_ value: Int) -> String { "\(value) 天前" } } } // MARK: - 写操作失败文案 /// git 写操作(stage/commit/push/fetch/worktree)的错误话术**单一出口**。 /// /// 纪律:服务器已经把 git stderr 分类成安全短句(SEC-M10),客户端**原样显示** /// 那句话 —— 既不吞掉(用户会以为操作成功了),也不自己编造原因。只有服务器 /// 没给 message 时才落到调用方的兜底短语。 enum GitWriteFeedback { static func message(status: Int, serverMessage: String?, fallback: String) -> String { guard let serverMessage, !serverMessage.isEmpty else { return "\(fallback)(HTTP \(status))" } return serverMessage } /// 抛出的错误:`APIClientError` 自带面向用户的话术(含 401 引导补令牌), /// 其余(传输层)落兜底短语 —— 绝不把 `localizedDescription` 当主文案, /// 它是英文系统串。 static func message(for error: any Error, fallback: String) -> String { (error as? APIClientError)?.message ?? fallback } /// 429:限流是服务器的保护机制,**绝不**自动重试。 static let rateLimited = APIClientError.rateLimited.message }