import APIClient import Foundation import SwiftUI /// C2 · git 面板的可复用子视图 —— 全部只消费 `DS` token(零硬编码颜色/间距), /// 服务器字节一律 `Text(verbatim:)`,触达目标 ≥ `DS.Layout.minHitTarget`。 // MARK: - 同步带(w6/G3 的手机竖排版本) /// 竖排三行的同步状态:HEAD/上游 · 待推送 · 待拉取(+ 脏度)。 /// /// 视觉语义就是那条设计铁律:**只有「已同步」可以用绿色**(`statusWorking`), /// 「未核实/无上游/分离 HEAD」用琥珀(`statusWaiting`),其余中性。数字本身从不 /// 被染成警告色 —— 落后是信息不是错误,怀疑由芯片和脚注承担(镜像 web 的修正)。 struct GitSyncBandView: View { let band: GitSyncBand /// 当前分支(服务器字节)。 let branch: String? var body: some View { VStack(alignment: .leading, spacing: DS.Space.sm8) { headRow if case .tracking(_, let ahead, let behind) = band.head { countsRow(ahead: ahead, behind: behind) } if let footnote { Text(footnote) .font(DS.Typography.caption) .foregroundStyle( band.isInSync ? DS.Palette.textSecondary : DS.Palette.statusWaiting ) .fixedSize(horizontal: false, vertical: true) } dirtyRow } .padding(.vertical, DS.Space.xs4) } @ViewBuilder private var headRow: some View { HStack(spacing: DS.Space.sm8) { if let branch { Label { Text(verbatim: branch).lineLimit(1) } icon: { Image(systemName: "arrow.triangle.branch") } .font(DS.Typography.callout) .foregroundStyle(DS.Palette.textPrimary) } switch band.head { case .detached: SyncChip(text: GitPanelCopy.detachedHead, tone: .warning) case .noUpstream: SyncChip(text: GitPanelCopy.noUpstream, tone: .warning) case .tracking(let upstream, _, _): // 上游名是服务器字节。 Text(verbatim: upstream) .font(DS.Typography.mono(.caption)) .foregroundStyle(DS.Palette.textSecondary) .lineLimit(1) .truncationMode(.middle) if band.isInSync { SyncChip(text: GitPanelCopy.inSync, tone: .good) } } Spacer(minLength: 0) } } private func countsRow(ahead: Int?, behind: Int?) -> some View { HStack(spacing: DS.Space.md12) { countCell(caption: GitPanelCopy.toPushCaption, symbol: "arrow.up", value: ahead) countCell(caption: GitPanelCopy.toPullCaption, symbol: "arrow.down", value: behind) if !band.isBehindVerified { SyncChip(text: GitPanelCopy.unverified, tone: .warning) } Spacer(minLength: 0) } } /// nil 计数渲染成 `—`:那是"算不出来",把它当 0 正是本类型要防的谎。 private func countCell(caption: String, symbol: String, value: Int?) -> some View { HStack(spacing: DS.Space.xs4) { Image(systemName: symbol) .imageScale(.small) Text(value.map(String.init) ?? GitPanelCopy.unknownCount) .font(DS.Typography.mono(.callout)) Text(caption) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) } .foregroundStyle(DS.Palette.textPrimary) .accessibilityElement(children: .combine) } @ViewBuilder private var dirtyRow: some View { switch band.dirty { case .unknown: Text(GitPanelCopy.dirtyUnknown) .dsMetaText() case .clean: SyncChip(text: GitPanelCopy.clean, tone: .good) case .changed(let count): SyncChip(text: GitPanelCopy.dirtyCount(count), tone: .dirty) } } /// 为什么这个数字不可信 —— 缺了这句话,读者最容易把"没有数字"读成"没事"。 private var footnote: String? { switch band.head { case .detached: return GitPanelCopy.detachedDetail case .noUpstream: return GitPanelCopy.noUpstreamDetail case .tracking: guard !band.isBehindVerified else { return nil } return GitPanelCopy.staleFetch } } } /// 项目详情**头部**的极简版同步态:一行胶囊,回答"有没有没推的提交"。 /// /// 与面板版共享同一份 `GitSyncBand` 判断,所以两处永不打架:绿色只可能是 /// 「已同步」,`nil` 计数显示 `—` 而不是 0,过期的 `↓` 一定带「未核实」。 struct SyncSummaryChips: View { let band: GitSyncBand var body: some View { HStack(spacing: DS.Space.xs4) { switch band.head { case .detached: SyncChip(text: GitPanelCopy.detachedHead, tone: .warning) case .noUpstream: SyncChip(text: GitPanelCopy.noUpstream, tone: .warning) case .tracking(_, let ahead, let behind): if band.isInSync { SyncChip(text: GitPanelCopy.inSync, tone: .good) } else { trackingChips(ahead: ahead, behind: behind) } } Spacer(minLength: 0) } } @ViewBuilder private func trackingChips(ahead: Int?, behind: Int?) -> some View { if let ahead, ahead > 0 { SyncChip(text: "↑ \(ahead)", tone: .neutral) } if let behind, behind > 0 { SyncChip(text: "↓ \(behind)", tone: .neutral) } if ahead == nil || behind == nil { // 读不到就说读不到 —— 绝不显示成 0。 SyncChip(text: "↑↓ \(GitPanelCopy.unknownCount)", tone: .warning) } if !band.isBehindVerified { SyncChip(text: GitPanelCopy.unverified, tone: .warning) } } } /// 小胶囊:语义色 + 淡底(与 `DirtyBadge` 同一做法,明暗两态都清晰)。 struct SyncChip: View { enum Tone { case good case warning case dirty case neutral } let text: String var tone: Tone = .neutral private var color: Color { switch tone { case .good: return DS.Palette.statusWorking case .warning: return DS.Palette.statusWaiting case .dirty: return DS.Palette.statusWaiting case .neutral: return DS.Palette.textSecondary } } var body: some View { Text(text) .font(DS.Typography.caption.weight(.medium)) .foregroundStyle(color) .padding(.horizontal, DS.Space.sm8) .padding(.vertical, DS.Space.xs2) .background(color.opacity(Self.fillOpacity), in: Capsule()) .lineLimit(1) } /// 软填充胶囊的底色透明度(与 `DirtyBadge` 一致)。 private static let fillOpacity: Double = 0.18 } // MARK: - 未推送边界(w6/G4) /// `git log` 列表里的一条分界线:以上尚未推送到 ``。 struct UnpushedBoundary: View { let upstream: String var body: some View { HStack(spacing: DS.Space.sm8) { Rectangle() .fill(DS.Palette.statusWaiting) .frame(height: DS.Stroke.hairline) // upstream 是服务器字节 → 拼进文案前仍走 verbatim 渲染。 Text(verbatim: GitPanelCopy.unpushedBoundary(upstream)) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.statusWaiting) .lineLimit(1) Rectangle() .fill(DS.Palette.statusWaiting) .frame(height: DS.Stroke.hairline) } .padding(.vertical, DS.Space.xs2) } } // MARK: - 行内提示 /// 一行成功/失败提示。服务器安全文案原样显示(verbatim),绝不静默。 struct InlineMessage: View { enum Tone { case error case success case info } let text: String var tone: Tone = .info private var color: Color { switch tone { case .error: return DS.Palette.statusStuck case .success: return DS.Palette.statusWorking case .info: return DS.Palette.textSecondary } } private var symbol: String { switch tone { case .error: return "exclamationmark.triangle" case .success: return "checkmark.circle" case .info: return "info.circle" } } var body: some View { HStack(alignment: .top, spacing: DS.Space.sm8) { Image(systemName: symbol) .foregroundStyle(color) Text(verbatim: text) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textPrimary) .fixedSize(horizontal: false, vertical: true) } .accessibilityElement(children: .combine) } } // MARK: - PR / CI 芯片(w3-pr-ci-chip 的移动端对齐) /// 一行 PR 状态 + CI 汇总。 /// /// gh 的降级(未安装/未登录/无 PR/已关闭/出错)在服务端是 200 + `availability`, /// 所以这里一条分支都不当错误处理 —— 芯片照常在,只是换句话说。 struct PrStatusRow: View { let status: PrStatus var body: some View { VStack(alignment: .leading, spacing: DS.Space.xs4) { HStack(spacing: DS.Space.sm8) { TelemetryChip(systemImage: "arrow.triangle.pull", text: headline) if let checks = status.checks, checks.total > 0 { TelemetryChip( systemImage: checkSymbol(checks), text: PrCopy.checks(checks), isWarning: checks.failing > 0 ) } Spacer(minLength: 0) } if let title = status.title, !title.isEmpty { // PR 标题是 GitHub 上的任意文本 → verbatim。 Text(verbatim: title) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) .lineLimit(2) } if let url = PrLink.url(from: status.url) { Link(destination: url) { Label(PrCopy.openInBrowser, systemImage: "safari") .font(DS.Typography.caption) } .frame(minHeight: DS.Layout.minHitTarget, alignment: .leading) } } } private var headline: String { switch status.availability { case .ok: return PrCopy.number(status.number, state: status.state, isDraft: status.isDraft) case .noPr: return PrCopy.noPr case .notInstalled: return PrCopy.notInstalled case .unauthenticated: return PrCopy.unauthenticated case .disabled: return PrCopy.disabled case .error: return PrCopy.error } } private func checkSymbol(_ checks: PrCheckSummary) -> String { if checks.failing > 0 { return "xmark.octagon" } if checks.pending > 0 { return "clock" } return "checkmark.seal" } } /// PR 链接白名单:URL 是**服务器字节**,直接丢给 `openURL` 等于让远端选择要打开 /// 哪个 App(自定义 scheme 可以跳出浏览器)。因此只接受 https + github.com。 enum PrLink { private static let allowedScheme = "https" private static let allowedHostSuffix = "github.com" static func url(from raw: String?) -> URL? { guard let raw, let url = URL(string: raw), url.scheme?.lowercased() == allowedScheme, let host = url.host?.lowercased(), host == allowedHostSuffix || host.hasSuffix("." + allowedHostSuffix) else { return nil } return url } } enum PrCopy { static let noPr = "无 PR" static let notInstalled = "主机未安装 gh" static let unauthenticated = "gh 未登录" static let disabled = "PR 查询已关闭" static let error = "PR 状态获取失败" static let openInBrowser = "在浏览器打开" static let draft = "草稿" static func number(_ number: Int?, state: String?, isDraft: Bool?) -> String { var text = number.map { "PR #\($0)" } ?? "PR" if let state, !state.isEmpty { text += " · \(state)" } if isDraft == true { text += " · \(draft)" } return text } static func checks(_ checks: PrCheckSummary) -> String { "✓\(checks.passing) ✗\(checks.failing) ⏳\(checks.pending)" } }