Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions Sources/AskKeyAppKit/App/ApprovalCardComponents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import AppKit
import SwiftUI

/// A fixed card section: its heading, an optional status tag and trailing
/// control, then content. The overflow line names it while it is hidden.
struct ApprovalSection<Content: View, Accessory: View>: View {
let title: String
let tag: ApprovalTag?
let name: String?
let identifier: String
let accessory: Accessory
let content: Content

init(title: String, tag: ApprovalTag? = nil, name: String? = nil, identifier: String,
@ViewBuilder accessory: () -> Accessory, @ViewBuilder content: () -> Content) {
self.title = title
self.tag = tag
self.name = name
self.identifier = identifier
self.accessory = accessory()
self.content = content()
}

var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) {
Text(verbatim: title)
.font(Theme.Fonts.caption.weight(.semibold))
.foregroundStyle(Theme.textSecondary)
.fixedSize(horizontal: false, vertical: true)
if let tag { ApprovalTagView(tag: tag) }
Spacer(minLength: Theme.Spacing.sm)
accessory
}
content
.font(Theme.Fonts.secondary)
.foregroundStyle(Theme.text)
}
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.approvalScrollMarker(name: name ?? title)
.accessibilityElement(children: .contain)
.accessibilityIdentifier(identifier)
}
}

extension ApprovalSection where Accessory == EmptyView {
init(title: String, tag: ApprovalTag? = nil, name: String? = nil, identifier: String,
@ViewBuilder content: () -> Content) {
self.init(title: title, tag: tag, name: name, identifier: identifier, accessory: { EmptyView() }, content: content)
}
}

struct ApprovalTagView: View {
let tag: ApprovalTag

var body: some View {
Text(verbatim: tag.title)
.font(Theme.Fonts.caption.weight(.semibold))
.foregroundStyle(tag.color)
.padding(.horizontal, Theme.Spacing.xs)
.padding(.vertical, 1)
.background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Spacing.xs))
.fixedSize()
}
}

extension ApprovalTag {
/// Grey for no change, blue for edits, green for additions, orange for
/// replaced values and merges, red for removals; new groups stay neutral.
var color: Color {
switch self {
case .unchanged, .noChange: return Theme.textSecondary
case .changed: return ApprovalTagPalette.blue
case .new: return ApprovalTagPalette.green
case .newGroup: return Theme.text
case .replaced, .merge: return ApprovalTagPalette.orange
case .removed: return Theme.warning
}
}
}

private enum ApprovalTagPalette {
static let blue = dynamic(light: 0x0A60C2, dark: 0x6CB6FF)
static let green = dynamic(light: 0x1B7F3B, dark: 0x5FD38A)
static let orange = dynamic(light: 0xB35300, dark: 0xFFA657)

private static func dynamic(light: UInt32, dark: UInt32) -> Color {
Color(nsColor: NSColor(name: nil) { appearance in
let hex = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light
return NSColor(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
})
}
}

/// Card text whose variable names render in the monospaced face. A status
/// tag leads the first line and the text wraps under it at full width.
struct ApprovalLineText: View {
let line: ApprovalLine
var tag: ApprovalTag?

var body: some View {
Text(attributed)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}

private var attributed: AttributedString {
var result = AttributedString()
if let tag {
var label = AttributedString("\u{2009}" + tag.title + "\u{2009}")
label.font = Theme.Fonts.caption.weight(.semibold)
label.foregroundColor = tag.color
label.backgroundColor = Theme.neutralSubtle
result += label + AttributedString(" ")
}
for segment in line.segments {
var part = AttributedString(segment.text)
if segment.code { part.font = Theme.Fonts.mono }
result += part
}
return result
}
}

enum ApprovalButtonRole: Equatable {
case primary, secondary, destructive
}

/// Full-width stacked alert button: the one filled accent action, a white
/// bordered secondary action, or a bordered red action whose effect is hard
/// to undo. "Deny" is secondary, never red.
struct ApprovalPromptButton: View {
let title: String
let role: ApprovalButtonRole
let action: () -> Void

var body: some View {
Button(action: action) {
Text(title)
.font(Theme.Fonts.body)
.foregroundStyle(foreground)
.lineLimit(1)
.minimumScaleFactor(0.85)
.padding(.horizontal, Theme.Spacing.sm)
.frame(maxWidth: .infinity)
.frame(height: Theme.controlHeight)
.background(role == .primary ? Theme.accent : Theme.surface, in: .rect(cornerRadius: Theme.Radius.control))
.overlay {
if role != .primary {
RoundedRectangle(cornerRadius: Theme.Radius.control)
.stroke(role == .destructive ? Theme.warning.opacity(0.45) : Theme.separator)
}
}
.shadow(color: Theme.cardShadow, radius: 1, y: 0.5)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}

private var foreground: Color {
switch role {
case .primary: return Theme.onAccent
case .secondary: return Theme.text
case .destructive: return Theme.warning
}
}
}
119 changes: 119 additions & 0 deletions Sources/AskKeyAppKit/App/ApprovalCopy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import Foundation
import AskKeyBroker

/// Shared wording for approval cards and the pending list. Credential and
/// group names are quoted the catalog's way and only break inside when one
/// line cannot hold them.
enum ApprovalCopy {
static func requester(_ request: BrokerApprovalOperationRequest) -> String {
request.callerName.flatMap { $0.isEmpty ? nil : $0 } ?? appLocalized("Local Agent")
}

static func quoted(_ name: String) -> String {
quote(unbreakable(name))
}

/// Quotes free text, such as a removed phrase, without keeping it whole.
static func quote(_ text: String) -> String {
appLocalizedFormat("Quoted name: %@", text)
}

/// "the value of “X”" in the current language, kept on one line together.
static func credentialValue(_ name: String) -> String {
unbreakable(appLocalizedFormat("Credential value: %@", quote(name)))
}

/// Word joiners and no-break spaces keep a name on one line when it fits;
/// a name longer than a line still wraps by character.
static func unbreakable(_ name: String) -> String {
let joiner: Character = "\u{2060}"
var result = String(joiner)
for character in name {
result.append(character == " " ? "\u{00A0}" : character)
result.append(joiner)
}
return result
}

static func group(_ name: String?) -> String {
quoted(name ?? appLocalized("Ungrouped"))
}

static func bytes(_ count: Int) -> String {
count == 1
? appLocalized("1 byte")
: appLocalizedFormat("%@ bytes", count.formatted(.number.locale(AppLanguage.locale(for: AppLanguage.current))))
}

/// Rule-5 delivery wording; the variable name is the code argument.
static func delivery(_ delivery: BrokerComponentDelivery) -> ApprovalLine {
switch delivery {
case .environmentVariable(let variable):
return ApprovalLine(format: appLocalized("given to programs as environment variable %@"), code: [variable])
case .temporaryFile(let variable):
return ApprovalLine(format: appLocalized("given to programs as a temporary file (path in %@)"), code: [variable])
case .none:
return ApprovalLine(appLocalized("kept in Ask Key only, never given to agents"))
}
}

/// "3 credentials (2 hidden from agents)"; the clause is omitted at zero.
static func members(_ count: Int, hidden: Int) -> String {
let members = count == 1
? appLocalizedFormat("%lld credential", count)
: appLocalizedFormat("%lld credentials", count)
return hidden > 0 ? appLocalizedFormat("%1$@ (%2$lld hidden from agents)", members, hidden) : members
}

static func sentences(_ parts: [String]) -> String {
parts.joined(separator: appLocalized("Sentence separator"))
}
}

/// One line of card text in which variable names render as code.
struct ApprovalLine: Equatable {
struct Segment: Equatable {
let text: String
let code: Bool
}

let segments: [Segment]

var plainText: String { segments.map(\.text).joined() }

init(_ text: String) {
segments = text.isEmpty ? [] : [Segment(text: text, code: false)]
}

/// Splits a localized `%@` format; arguments listed in `code` render as code.
init(format: String, plain: [String] = [], code: [String] = []) {
let arguments = plain + code
segments = EmphasizedSentence(format: format, arguments: arguments).runs.map { run in
Segment(text: run.text, code: run.argument.map { $0 >= plain.count } ?? false)
}
}

private init(segments: [Segment]) { self.segments = segments }

static func + (lhs: ApprovalLine, rhs: ApprovalLine) -> ApprovalLine {
ApprovalLine(segments: lhs.segments + rhs.segments)
}
}

/// Small status labels on neutral backgrounds. Color carries risk, not novelty.
enum ApprovalTag: Equatable {
case unchanged, changed, new, newGroup, replaced, removed, noChange, merge

var title: String {
switch self {
case .unchanged: return appLocalized("Unchanged")
case .changed: return appLocalized("Changed")
case .new: return appLocalized("New")
case .newGroup: return appLocalized("New group tag")
case .replaced: return appLocalized("Replaced")
case .removed: return appLocalized("Removed")
case .noChange: return appLocalized("No change")
case .merge: return appLocalized("Merge")
}
}
}
Loading
Loading