import AppKit import ByreAPI import OpenAPIRuntime import SwiftUI typealias JSONDict = [String: (any Sendable)?] typealias Review = Components.Schemas.Review let taskPalette = [ Palette.task0, Palette.task1, Palette.task2, Palette.task3, Palette.task4, Palette.task5, Palette.task6, Palette.task7, Palette.task8, Palette.task9, Palette.task10, ] func str(_ d: JSONDict, _ key: String) -> String? { d[key] as? String } func int(_ d: JSONDict, _ key: String) -> Int? { if let i = d[key] as? Int { return i } return (d[key] as? Double).map { Int($0) } } func bool(_ d: JSONDict, _ key: String) -> Bool { d[key] as? Bool == true } func dict(_ d: JSONDict, _ key: String) -> JSONDict? { d[key] as? JSONDict } func jsString(_ v: (any Sendable)??) -> String { switch v { case .none, .some(.none): return "undefined" case .some(.some(let x)): if let s = x as? String { return s } if let b = x as? Bool { return b ? "true" : "false" } if let i = x as? Int { return String(i) } if let d = x as? Double { return d == d.rounded() && abs(d) < 1e15 ? String(Int(d)) : String(d) } return prettyJSON(x) } } func prettyJSON(_ v: (any Sendable)?) -> String { jsonText(v, indent: " ", depth: 0) } func compactJSON(_ v: (any Sendable)?) -> String { jsonText(v, indent: nil, depth: 0) } private func jsonText(_ v: (any Sendable)?, indent: String?, depth: Int) -> String { guard let v else { return "null" } if let s = v as? String { return jsonQuote(s) } if let b = v as? Bool { return b ? "true" : "false" } if let i = v as? Int { return String(i) } if let d = v as? Double { return d.isFinite ? jsString(d) : "null" } let items: [String] let open: String let close: String if let list = v as? [(any Sendable)?] { items = list.map { jsonText($0, indent: indent, depth: depth + 1) } open = "[" close = "]" } else if let d = v as? JSONDict { let keys = d.keys.sorted { $0.utf8.lexicographicallyPrecedes($1.utf8) } items = keys.map { "\(jsonQuote($0)):\(indent == nil ? "" : " ")\(jsonText(d[$0] ?? nil, indent: indent, depth: depth + 1))" } open = "{" close = "}" } else { return jsonQuote(String(describing: v)) } if items.isEmpty { return open + close } guard let indent else { return open + items.joined(separator: ",") + close } let inner = "\n" + String(repeating: indent, count: depth + 1) return open + inner + items.joined(separator: "," + inner) + "\n" + String(repeating: indent, count: depth) + close } private func jsonQuote(_ s: String) -> String { var out = "\"" for u in s.unicodeScalars { switch u { case "\"": out += "\\\"" case "\\": out += "\\\\" case "\u{08}": out += "\\b" case "\u{0C}": out += "\\f" case "\n": out += "\\n" case "\r": out += "\\r" case "\t": out += "\\t" default: if u.value < 0x20 { let hex = String(u.value, radix: 16) out += "\\u" + String(repeating: "0", count: 4 - hex.count) + hex } else { out.unicodeScalars.append(u) } } } return out + "\"" } func ago(_ date: Date) -> String { let seconds = Int((Date().timeIntervalSince(date)).rounded()) if seconds < 60 { return "just now" } if seconds < 3600 { return "\(seconds / 60)m ago" } if seconds < 86400 { return "\(seconds / 3600)h ago" } return "\(seconds / 86400)d ago" } func isoText(_ date: Date) -> String { date.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true)) } func clockText(_ d: Date) -> String { d.formatted(Date.VerbatimFormatStyle(format: "\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits):\(second: .twoDigits)", timeZone: .current, calendar: .current)) } func trimmed(_ s: String) -> String { s.trimmingCharacters(in: .whitespacesAndNewlines) } extension Store { func taskColor(_ taskId: String) -> Color? { findTask(taskId).map { taskPalette[$0.color] } } } private struct PaneAccentKey: EnvironmentKey { static let defaultValue = Palette.accent } private struct InRightPaneKey: EnvironmentKey { static let defaultValue = false } extension EnvironmentValues { var paneAccent: Color { get { self[PaneAccentKey.self] } set { self[PaneAccentKey.self] = newValue } } var inRightPane: Bool { get { self[InRightPaneKey.self] } set { self[InRightPaneKey.self] = newValue } } } struct Copyable: View { let text: String @ViewBuilder let content: Content @ViewState private var hover = false @ViewState private var copied = false var body: some View { content .overlay(alignment: .topTrailing) { if hover { Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) copied = true Task { try? await Task.sleep(for: .milliseconds(1500)) copied = false } } label: { Image(systemName: copied ? "checkmark" : "doc.on.doc") .font(.system(size: 11)) .foregroundStyle(Palette.muted) .padding(4) .background(Palette.raised, in: RoundedRectangle(cornerRadius: 4)) } .buttonStyle(.plain) .help("Copy to the clipboard") .padding(4) } } .onHover { hover = $0 } } } struct PreBox: View { let text: String var maxHeight: CGFloat? = 320 var size: CGFloat = 12 var body: some View { Copyable(text: text) { ScrollView { Text(verbatim: text) .font(Fonts.font(size)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) .padding(.vertical, 6) } .scrollBounceBehavior(.basedOnSize) .frame(maxHeight: maxHeight) .fixedSize(horizontal: false, vertical: true) .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) } } } struct Spinner: View { var color: Color @Environment(\.font) private var font @Environment(\.fontResolutionContext) private var fontContext var body: some View { Text("⠋") .hidden() .overlay { SpinnerGlyphs(font: (font ?? .body).resolve(in: fontContext).ctFont, color: color) .allowsHitTesting(false) } } } private struct SpinnerGlyphs: NSViewRepresentable { let font: CTFont let color: Color func makeNSView(context: Context) -> SpinnerView { SpinnerView() } func updateNSView(_ v: SpinnerView, context: Context) { v.font = font v.color = color.resolve(in: context.environment).cgColor v.needsLayout = true } } private final class SpinnerView: NSView { private struct Key: Equatable { let size: CGSize let scale: CGFloat let font: CTFont let color: CGColor } private static let frames = Array("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") var font: CTFont? var color: CGColor? private let glyph = CALayer() private var drawn: Key? override init(frame: NSRect) { super.init(frame: frame) wantsLayer = true layer?.addSublayer(glyph) } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() needsLayout = true } override func layout() { super.layout() guard let font, let color, let window, bounds.width > 0, bounds.height > 0 else { return } let key = Key(size: bounds.size, scale: window.backingScaleFactor, font: font, color: color) if key == drawn { return } drawn = key let images = Self.frames.map { render(String($0), key) } CATransaction.begin() CATransaction.setDisableActions(true) glyph.frame = bounds glyph.contentsScale = key.scale glyph.contents = images[0] let spin = CAKeyframeAnimation(keyPath: "contents") spin.values = images spin.keyTimes = (0...images.count).map { NSNumber(value: Double($0) / Double(images.count)) } spin.calculationMode = .discrete spin.duration = 0.08 * Double(images.count) spin.repeatCount = .infinity spin.isRemovedOnCompletion = false glyph.add(spin, forKey: "spin") CATransaction.commit() } private func render(_ s: String, _ key: Key) -> CGImage { let ctx = CGContext(data: nil, width: Int(ceil(key.size.width * key.scale)), height: Int(ceil(key.size.height * key.scale)), bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! ctx.scaleBy(x: key.scale, y: key.scale) let attrs: [NSAttributedString.Key: Any] = [ NSAttributedString.Key(kCTFontAttributeName as String): key.font, NSAttributedString.Key(kCTForegroundColorAttributeName as String): key.color, ] let line = CTLineCreateWithAttributedString(NSAttributedString(string: s, attributes: attrs)) var ascent: CGFloat = 0 var descent: CGFloat = 0 let width = CTLineGetTypographicBounds(line, &ascent, &descent, nil) ctx.textPosition = CGPoint(x: (key.size.width - width) / 2, y: (key.size.height - ascent - descent) / 2 + descent) CTLineDraw(line, ctx) return ctx.makeImage()! } } struct Badge: View { let count: Int var body: some View { Text("\(count)") .font(Fonts.font(11, weight: 700)) .foregroundStyle(Palette.danger) .padding(.horizontal, 7) .background(Palette.dangerTint, in: Capsule()) } } struct Muted: View { let text: String var body: some View { Text(text) .foregroundStyle(Palette.muted) .padding(32) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) } } struct WebButtonStyle: ButtonStyle { var primary = false var danger = false func makeBody(configuration: ButtonStyleConfiguration) -> some View { WebButton(configuration: configuration, primary: primary, danger: danger) } } private struct WebButton: View { let configuration: ButtonStyleConfiguration let primary: Bool let danger: Bool @Environment(\.isEnabled) private var enabled @ViewState private var hover = false var body: some View { let fill = danger ? Palette.danger : Palette.accent configuration.label .font(Fonts.font(13, weight: primary ? 700 : 400)) .foregroundStyle(primary ? Palette.bg : danger ? Palette.danger : Palette.text) .padding(.horizontal, 12) .padding(.vertical, 4) .background(primary ? fill : Palette.raised, in: RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(primary ? fill : hover && enabled ? Palette.muted : Palette.border)) .opacity(enabled ? (configuration.isPressed ? 0.8 : 1) : 0.45) .contentShape(Rectangle()) .onHover { hover = $0 } } } struct SmallLinkButtonStyle: ButtonStyle { var color = Palette.accent @Environment(\.isEnabled) private var enabled func makeBody(configuration: ButtonStyleConfiguration) -> some View { configuration.label .font(Fonts.font(11)) .foregroundStyle(color) .padding(.horizontal, 6) .overlay(RoundedRectangle(cornerRadius: 4).stroke(Palette.border)) .opacity(enabled ? 1 : 0.45) .contentShape(Rectangle()) } } struct FieldStyle: ViewModifier { var accent: Color? = nil var focused = false func body(content: Content) -> some View { content .textFieldStyle(.plain) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? (accent ?? Palette.accent) : (accent ?? Palette.border))) } } struct HeaderBar: ViewModifier { func body(content: Content) -> some View { content .padding(.horizontal, 16) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .overlay(alignment: .bottom) { Rectangle().fill(Palette.border).frame(height: 1) } } } struct RangeTabButton: View { let label: String let active: Bool var help = "" let action: () -> Void @ViewState private var hover = false var body: some View { Button(action: action) { Text(label) .font(Fonts.font(12)) .foregroundStyle(active || hover ? Palette.text : Palette.muted) .padding(.horizontal, 6) .padding(.top, 2) .padding(.bottom, 3) .overlay(alignment: .bottom) { Rectangle().fill(active ? Palette.accent : .clear).frame(height: 2) } .contentShape(Rectangle()) } .buttonStyle(.plain) .help(help) .onHover { hover = $0 } } } struct EmptyText: View { let text: String var error = false var body: some View { VStack { Text(text) .foregroundStyle(error ? Palette.danger : Palette.muted) .multilineTextAlignment(.center) .textSelection(.enabled) .padding(32) .frame(maxWidth: .infinity) Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } struct StateDot: View { let filled: Color? var body: some View { Group { if let filled { Circle().fill(filled) } else { Circle().strokeBorder(Palette.muted, lineWidth: 1.5) } } .frame(width: 8, height: 8) } } struct StateMark: View { let state: TaskState let color: Color var body: some View { Group { switch state.kind { case .working, .merging: Spinner(color: color) case .answer: StateDot(filled: Palette.warn) case .error: StateDot(filled: Palette.danger) default: StateDot(filled: nil) } } .help(state.title) } } enum PatchLine { case hunk, add, del, meta, ctx init(_ l: String) { self = l.hasPrefix("@@") ? .hunk : l.hasPrefix("+") ? .add : l.hasPrefix("-") ? .del : l.hasPrefix("\\") ? .meta : .ctx } var background: Color { switch self { case .hunk: Palette.surface case .add: Palette.ok.opacity(0.14) case .del: Palette.danger.opacity(0.14) default: .clear } } var color: Color { self == .hunk || self == .meta ? Palette.muted : Palette.text } } struct StretchColumn: Layout { var minWidth: CGFloat = 0 private func width(_ proposal: ProposedViewSize, _ subviews: Subviews) -> CGFloat { max(minWidth, proposal.width ?? 0, subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0) } func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let w = width(ProposedViewSize(width: nil, height: nil), subviews) let h = subviews.reduce(0) { $0 + $1.sizeThatFits(ProposedViewSize(width: w, height: nil)).height } return CGSize(width: w, height: h) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { var y = bounds.minY for sub in subviews { let h = sub.sizeThatFits(ProposedViewSize(width: bounds.width, height: nil)).height sub.place(at: CGPoint(x: bounds.minX, y: y), proposal: ProposedViewSize(width: bounds.width, height: h)) y += h } } } struct TrailingBubble: Layout { var fraction: CGFloat = 0.85 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let width = proposal.width ?? 600 let size = subviews[0].sizeThatFits(ProposedViewSize(width: width * fraction, height: nil)) return CGSize(width: width, height: size.height) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let size = subviews[0].sizeThatFits(ProposedViewSize(width: bounds.width * fraction, height: nil)) subviews[0].place(at: CGPoint(x: bounds.maxX - size.width, y: bounds.minY), proposal: ProposedViewSize(size)) } } struct FitWidth: Layout { let natural: CGSize private func scaled(_ width: CGFloat?) -> CGSize { let w = width ?? natural.width let scale = natural.width > 0 ? min(1, w / natural.width) : 1 return CGSize(width: natural.width * scale, height: natural.height * scale) } func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { scaled(proposal.width) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let size = scaled(bounds.width) subviews[0].place(at: bounds.origin, proposal: ProposedViewSize(size)) } } struct FlowLayout: Layout { var spacing: CGFloat = 6 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let rows = arrange(proposal.width ?? .infinity, subviews) let height = rows.last.map { $0.y + $0.height } ?? 0 let width = rows.map(\.width).max() ?? 0 return CGSize(width: proposal.width ?? width, height: height) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { for row in arrange(bounds.width, subviews) { for (i, x) in zip(row.items, row.xs) { subviews[i].place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + row.y), proposal: .unspecified) } } } private struct Row { var items: [Int] = [] var xs: [CGFloat] = [] var y: CGFloat = 0 var width: CGFloat = 0 var height: CGFloat = 0 } private func arrange(_ maxWidth: CGFloat, _ subviews: Subviews) -> [Row] { var rows: [Row] = [] var row = Row() var y: CGFloat = 0 for (i, sub) in subviews.enumerated() { let size = sub.sizeThatFits(.unspecified) if !row.items.isEmpty && row.width + spacing + size.width > maxWidth { rows.append(row) y += row.height + spacing row = Row(y: y) } let x = row.items.isEmpty ? 0 : row.width + spacing row.items.append(i) row.xs.append(x) row.width = x + size.width row.height = max(row.height, size.height) } if !row.items.isEmpty { rows.append(row) } return rows } } let overlayShadow = Color(nsColor: NSColor(name: nil) { appearance in appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.5) : NSColor(srgbRed: 16 / 255, green: 24 / 255, blue: 40 / 255, alpha: 0.18) })