cmux/Sources/Update/UpdatePill.swift
2026-01-28 03:56:11 -08:00

114 lines
3.9 KiB
Swift

import AppKit
import Foundation
import SwiftUI
/// A pill-shaped button that displays update status and provides access to update actions.
struct UpdatePill: View {
@ObservedObject var model: UpdateViewModel
@State private var showPopover = false
@State private var resetTask: Task<Void, Never>?
private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium)
var body: some View {
let state = model.effectiveState
if !state.isIdle {
pillButton
.popover(
isPresented: $showPopover,
attachmentAnchor: .rect(.bounds),
arrowEdge: .top
) {
UpdatePopoverView(model: model)
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
.onAppear {
scheduleNoUpdateDismiss(for: model.effectiveState)
}
.onChange(of: model.effectiveState) { newState in
scheduleNoUpdateDismiss(for: newState)
}
}
}
@ViewBuilder
private var pillButton: some View {
Button(action: {
if case .notFound(let notFound) = model.state {
model.state = .idle
notFound.acknowledgement()
} else {
showPopover.toggle()
}
}) {
HStack(spacing: 6) {
UpdateBadge(model: model)
.frame(width: 14, height: 14)
Text(model.text)
.font(Font(textFont))
.lineLimit(1)
.truncationMode(.tail)
.frame(width: textWidth)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule()
.fill(model.backgroundColor)
)
.foregroundColor(model.foregroundColor)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.help(model.text)
.accessibilityLabel(model.text)
.accessibilityIdentifier("UpdatePill")
}
private var textWidth: CGFloat? {
let attributes: [NSAttributedString.Key: Any] = [.font: textFont]
let size = (model.maxWidthText as NSString).size(withAttributes: attributes)
return size.width
}
private func recordUITestTimestamp(key: String) {
#if DEBUG
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_MODE"] == "1" else { return }
guard let path = env["CMUX_UI_TEST_TIMING_PATH"] else { return }
let url = URL(fileURLWithPath: path)
var payload: [String: Double] = [:]
if let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Double] {
payload = object
}
payload[key] = Date().timeIntervalSince1970
if let data = try? JSONSerialization.data(withJSONObject: payload) {
try? data.write(to: url)
}
#endif
}
private func scheduleNoUpdateDismiss(for state: UpdateState) {
resetTask?.cancel()
if case .notFound(let notFound) = state, model.overrideState == nil {
recordUITestTimestamp(key: "noUpdateShownAt")
resetTask = Task { [weak model] in
let delay = UInt64(UpdateTiming.noUpdateDisplayDuration * 1_000_000_000)
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled, case .notFound? = model?.state else { return }
await MainActor.run {
withAnimation(.easeInOut(duration: 0.25)) {
recordUITestTimestamp(key: "noUpdateHiddenAt")
model?.state = .idle
}
}
notFound.acknowledgement()
}
} else {
resetTask = nil
}
}
}