From 8b2b19824d6279ed9e504961c1b572c1534aaa31 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Fri, 9 Oct 2026 21:26:38 +0800 Subject: [PATCH 1/3] Restore the v0.2 approval card style for every card Every approval card is again the v0.2 read card: the app icon, one bold sentence that names the requester and the object, one short line under it, a Details link, the buttons and a small footer. Write and organize cards use the same layout with their own titles, subtitles and buttons; the items, tags, instruction diff, group change, organize steps, value view, requester note and stated purpose move behind Details, which scrolls inside the 640-point cap so the buttons stay visible. A cancelled authentication keeps the same buttons in the same order and adds one gray line, so the default never becomes the 30-minute option. Irreversible actions (replacing or removing values, merging groups) are red and never the default. The Pending requests list says the same sentence as the card title. Card code and strings that became unused are removed; the per-component value digests are unchanged. Closes #177 --- .../App/ApprovalCardComponents.swift | 18 +- Sources/AskKeyAppKit/App/ApprovalCopy.swift | 50 +- .../App/ApprovalPromptContent.swift | 159 ++-- .../AskKeyAppKit/App/ApprovalScrollArea.swift | 178 +---- .../App/FrozenAgentApprovalPrompt.swift | 292 ++++--- .../App/FrozenApprovalActions.swift | 66 +- .../FrozenOrganizationApprovalContent.swift | 55 +- .../App/FrozenWriteApprovalContent.swift | 27 +- .../App/FrozenWriteSummaryContent.swift | 116 ++- .../Resources/Localizable.xcstrings | 714 +++++++++--------- .../PendingRequestPresentation.swift | 10 +- .../AskKeyAppTests/ApprovalCardFixtures.swift | 49 +- .../ApprovalCardLayoutTests.swift | 88 +-- .../ApprovalPromptContentTests.swift | 350 +++++---- .../FrozenWriteSummaryContentTests.swift | 86 ++- .../LocalizationUnificationTests.swift | 5 +- .../OrganizationApprovalContentTests.swift | 105 ++- .../ScreenPresentationTests.swift | 15 +- .../WorkspaceInteractionTests.swift | 9 +- Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 85 +-- 20 files changed, 1239 insertions(+), 1238 deletions(-) diff --git a/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift index 8578dcc..c6648d9 100644 --- a/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift +++ b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift @@ -1,21 +1,19 @@ 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. +/// A section of the card's Details: its heading, an optional status tag and +/// trailing control, then content. struct ApprovalSection: 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, + init(title: String, tag: ApprovalTag? = 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() @@ -38,16 +36,14 @@ struct ApprovalSection: View { } .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) + init(title: String, tag: ApprovalTag? = nil, identifier: String, @ViewBuilder content: () -> Content) { + self.init(title: title, tag: tag, identifier: identifier, accessory: { EmptyView() }, content: content) } } @@ -129,8 +125,8 @@ enum ApprovalButtonRole: Equatable { } /// 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. +/// bordered secondary action, or a bordered red action whose effect can't be +/// undone, which is never the default. "Deny" is secondary, never red. struct ApprovalPromptButton: View { let title: String let role: ApprovalButtonRole diff --git a/Sources/AskKeyAppKit/App/ApprovalCopy.swift b/Sources/AskKeyAppKit/App/ApprovalCopy.swift index 6d4e725..59ec0cc 100644 --- a/Sources/AskKeyAppKit/App/ApprovalCopy.swift +++ b/Sources/AskKeyAppKit/App/ApprovalCopy.swift @@ -1,38 +1,15 @@ 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. +/// Shared wording for approval cards and the pending list. Names are quoted +/// with “ ” in both languages, as on the v0.2 card. 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 + "“" + name + "”" } static func group(_ name: String?) -> String { @@ -68,6 +45,24 @@ enum ApprovalCopy { static func sentences(_ parts: [String]) -> String { parts.joined(separator: appLocalized("Sentence separator")) } + + /// "A, B and C" in the current language. + static func list(_ items: [String]) -> String { + guard let last = items.last else { return "" } + guard items.count > 1 else { return last } + return appLocalizedFormat("%1$@ and %2$@", items.dropLast().joined(separator: appLocalized("List separator")), last) + } + + /// Starts English text with a capital letter; Chinese is unchanged. + static func capitalized(_ text: String) -> String { + text.prefix(1).uppercased() + text.dropFirst() + } + + /// Names that the vault treats as the same item or group. + static func matchKey(_ name: String) -> String { + name.precomposedStringWithCanonicalMapping + .folding(options: [.caseInsensitive], locale: Locale(identifier: "en_US_POSIX")) + } } /// One line of card text in which variable names render as code. @@ -100,7 +95,8 @@ struct ApprovalLine: Equatable { } } -/// Small status labels on neutral backgrounds. Color carries risk, not novelty. +/// Small status labels on neutral backgrounds in Details. Color carries risk, +/// not novelty. enum ApprovalTag: Equatable { case unchanged, changed, new, newGroup, replaced, removed, noChange, merge diff --git a/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift b/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift index 070abb8..4a2308f 100644 --- a/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift +++ b/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift @@ -1,100 +1,125 @@ +import AppKit import AskKeyBroker -/// Copy shown by the approval card, derived from the pending request. The +/// The card's title, subtitle and Details rows, derived from the pending +/// request and, for write and organize cards, their frozen summaries. The /// requester's name and purpose are self-declared; the command, working /// directory and delivered names come from the App-derived display summary. struct ApprovalPromptContent: Equatable { struct Row: Equatable { let label: String let value: String - let monospaced: Bool + var monospaced = false } - let operation: BrokerApprovalOperation - let requester: String + /// One sentence naming who wants to do what with which object. let title: String - /// The whole command line, never shortened. - let command: String? - /// What a read hands the command if allowed: a heading naming the - /// credential and how many items, then one line per variable. - let receivesHeading: String - let receives: [ApprovalLine] - let purpose: String? - let detailRows: [Row] + /// Write and organize cards: one short line under the title. Read cards + /// show their command there instead. + let subtitle: String? + /// Read cards: the one-line command, already middle-truncated by the display summary. + let commandSummary: String? + let fullCommand: String? + let rows: [Row] init(request: BrokerApprovalOperationRequest, credentialName: String, - valueOnlyChange: Bool = false, organizationSteps: Int? = nil) { + write: FrozenWriteSummaryContent? = nil, organization: FrozenOrganizationSummaryContent? = nil) { let requester = ApprovalCopy.requester(request) - self.requester = requester - operation = request.operation - if request.operation == .organize { - title = Self.organizationTitle(requester: requester, steps: organizationSteps) - } else { - let object = request.operation == .modify && valueOnlyChange - ? ApprovalCopy.credentialValue(credentialName) : ApprovalCopy.quoted(credentialName) - title = EmphasizedSentence(format: Self.titleFormat(request.operation, valueOnlyChange: valueOnlyChange), - arguments: [requester, object]).plainText + switch request.operation { + case .read, .create, .delete: + title = Self.sentence(Self.titleFormat(request.operation), requester, credentialName) + case .modify: + title = Self.modifyTitle(requester: requester, credentialName: credentialName, write: write) + case .organize: + title = Self.sentence(Self.titleFormat(.organize), requester) + } + switch request.operation { + case .read: subtitle = nil + case .create, .modify: subtitle = write?.subtitle + case .delete: subtitle = appLocalized("Moves to the Recycle Bin; restorable for 30 days") + case .organize: subtitle = organization?.subtitle } let display = request.operation == .read ? request.display : nil - command = display?.commandLine - (receivesHeading, receives) = request.operation == .read - ? Self.receives(display, credentialName: credentialName) : ("", []) - purpose = request.callerPurpose.flatMap { $0.isEmpty ? nil : $0 } + commandSummary = display?.commandSummary + fullCommand = display?.commandLine var rows: [Row] = [] - if let directory = display?.workingDirectory, !directory.isEmpty { - rows.append(Row(label: appLocalized("Runs in"), value: directory, monospaced: true)) + if let display { + rows.append(Row(label: appLocalized("Command gets"), value: Self.receives(display))) + if let directory = display.workingDirectory, !directory.isEmpty { + rows.append(Row(label: appLocalized("Runs in"), value: directory, monospaced: true)) + } } rows.append(Row(label: appLocalized("Requested by"), - value: appLocalizedFormat("%@ (name provided by the requester; Ask Key can't verify it)", requester), - monospaced: false)) - detailRows = rows + value: appLocalizedFormat("%@ (name provided by the requester; Ask Key can't verify it)", requester))) + if let purpose = request.callerPurpose, !purpose.isEmpty { + rows.append(Row(label: appLocalized("Stated purpose"), value: appLocalizedFormat("%@ (not verified)", purpose))) + } + self.rows = rows } - /// Shared with the pending list so both name the object the same way. - static func titleFormat(_ operation: BrokerApprovalOperation, valueOnlyChange: Bool = false) -> String { + /// Shared with the pending list so both say the same sentence. + static func titleFormat(_ operation: BrokerApprovalOperation) -> String { switch operation { - case .read: return appLocalized("%1$@ wants to use the credential %2$@") - case .create: return appLocalized("%1$@ wants to create the credential %2$@") - case .modify: - return valueOnlyChange - ? appLocalized("%1$@ wants to replace %2$@") - : appLocalized("%1$@ wants to change the credential %2$@") - case .delete: return appLocalized("%1$@ wants to delete the credential %2$@") - case .organize: return appLocalized("%@ wants to reorganize your groups") + case .read: return appLocalized("“%1$@” wants to use “%2$@”") + case .create: return appLocalized("“%1$@” wants to create the credential “%2$@”") + case .modify: return appLocalized("“%1$@” wants to change “%2$@”") + case .delete: return appLocalized("“%1$@” wants to delete “%2$@”") + case .organize: return appLocalized("“%@” wants to organize your groups") } } - static func organizationTitle(requester: String, steps: Int?) -> String { - guard let steps else { return appLocalizedFormat("%@ wants to reorganize your groups", requester) } - return steps == 1 - ? appLocalizedFormat("%@ wants to reorganize your groups (1 step)", requester) - : appLocalizedFormat("%1$@ wants to reorganize your groups (%2$lld steps)", requester, steps) + /// A change that only replaces values names them: the value of the + /// credential, or the replaced items when others stay as they are. + private static func modifyTitle(requester: String, credentialName: String, + write: FrozenWriteSummaryContent?) -> String { + guard let write, write.valueOnlyChange else { + return sentence(titleFormat(.modify), requester, credentialName) + } + let replaced = write.components.filter { $0.tag == .replaced }.map(\.name) + if replaced.count < write.components.count { + return sentence(appLocalized("“%1$@” wants to replace %3$@ in “%2$@”"), requester, credentialName, + replaced.joined(separator: appLocalized("List separator"))) + } + return replaced.count == 1 + ? sentence(appLocalized("“%1$@” wants to replace the value of “%2$@”"), requester, credentialName) + : sentence(appLocalized("“%1$@” wants to replace the values of “%2$@”"), requester, credentialName) } - var cancelledAuthenticationNote: String { - operation == .read - ? appLocalized("You cancelled authentication. Nothing was given to the command, and the request is still pending.") - : appLocalized("You cancelled authentication. Nothing was changed, and the request is still pending.") + private static func sentence(_ format: String, _ arguments: String...) -> String { + EmphasizedSentence(format: format, arguments: arguments).plainText } - /// Variable names only; a multi-item credential's mapping is not opened - /// before approval, so its items are described without names. - static func receives(_ display: BrokerApprovalOperationRequest.Display?, - credentialName: String) -> (heading: String, rows: [ApprovalLine]) { - let heading = appLocalized("If you allow, this command receives") - guard let display else { return (heading, []) } - let name = ApprovalCopy.quoted(credentialName) + /// What the command gets and how, one line per variable. A multi-item + /// credential's mapping is not opened before approval, so its items are + /// described without names. + static func receives(_ display: BrokerApprovalOperationRequest.Display) -> String { guard let environment = display.environmentVariables, let files = display.temporaryFileVariables else { - return (heading, [ApprovalLine(appLocalizedFormat( - "The items of %@ that are set to be given to programs (names are shown after you approve)", name))]) - } - let rows = environment.map { ApprovalLine(format: appLocalized("→ Environment variable %@"), code: [$0]) } - + files.map { ApprovalLine(format: appLocalized("→ Temporary file (path in %@, removed within 5 minutes)"), code: [$0]) } - switch rows.count { - case 0: return (heading, [ApprovalLine(appLocalized("Nothing from this credential is given to the command"))]) - case 1: return (appLocalizedFormat("If you allow, this command receives 1 item from %@", name), rows) - default: - return (appLocalizedFormat("If you allow, this command receives %1$lld items from %2$@", rows.count, name), rows) + return appLocalized("The items set to be given to programs (names are shown after you approve)") } + let lines = environment.map { appLocalizedFormat("Environment variable %@", $0) } + + files.map { appLocalizedFormat("Temporary file (path in %@, removed within 5 minutes)", $0) } + return lines.isEmpty + ? appLocalized("Nothing from this credential is given to the command") + : lines.joined(separator: "\n") + } + + /// Rows for Details. The full command leads when the one-line subtitle + /// cannot show all of it. + func detailRows(commandFits: Bool) -> [Row] { + guard let fullCommand, !fullCommand.isEmpty, !commandFits else { return rows } + return [Row(label: appLocalized("Command"), value: fullCommand, monospaced: true)] + rows + } + + /// Whether "to run " shows the whole command on one line of the + /// given width: not truncated by the display summary and not clipped. + func commandFits(prefix: String, width: CGFloat) -> Bool { + guard let commandSummary, commandSummary == fullCommand else { return false } + let prefixWidth = (prefix + " " as NSString).size( + withAttributes: [.font: NSFont.systemFont(ofSize: 13)] + ).width + let commandWidth = (commandSummary as NSString).size( + withAttributes: [.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)] + ).width + return prefixWidth + commandWidth <= width } } diff --git a/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift index 0a70710..82c8261 100644 --- a/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift +++ b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift @@ -1,89 +1,42 @@ import SwiftUI -/// What the overflow line counts while a capped region hides content. -enum ApprovalOverflowUnit: Equatable { - case lines(height: CGFloat) - case steps - case sections -} - -enum ApprovalOverflow { - /// The line shown while content is hidden below; nil once the end is - /// visible. Steps are counted; other hidden sections are named. - static func hint(unit: ApprovalOverflowUnit, hiddenHeight: CGFloat, hiddenSteps: Int = 0, - hiddenNames: [String] = []) -> String? { - guard hiddenHeight > 1 else { return nil } - if case .lines(let height) = unit { - let lines = max(1, Int(((hiddenHeight - 1) / max(height, 1)).rounded(.up))) - return lines == 1 - ? appLocalized("1 more line — scroll to see it") - : appLocalizedFormat("%lld more lines — scroll to see them", lines) - } - if unit == .steps, hiddenSteps > 0 { - return hiddenSteps == 1 - ? appLocalized("1 more step — scroll to see it") - : appLocalizedFormat("%lld more steps — scroll to see them", hiddenSteps) - } - guard !hiddenNames.isEmpty else { return appLocalized("More below — scroll to see it") } - return appLocalizedFormat("Below: %@", hiddenNames.joined(separator: appLocalized("List separator"))) - } -} - -/// A capped region that never cuts content silently: while its content is -/// taller than the space it gets, a scroll bar stays visible and a line under -/// it says how much is still below. +/// A region that scrolls when its content is taller than the space it gets. +/// While anything is hidden, a thin bar stays visible beside it. struct ApprovalScrollArea: View { let space: String - let unit: ApprovalOverflowUnit var maxHeight: CGFloat? + var alignment: Alignment = .leading var indicatorOffset: CGFloat = 10 var identifier: String? - @ViewBuilder let content: (ScrollViewProxy) -> Content + @ViewBuilder let content: () -> Content @State private var contentFrame: CGRect = .zero @State private var viewport: CGFloat = 0 - @State private var markers: [ApprovalScrollMarkerValue] = [] var body: some View { - VStack(spacing: Theme.Spacing.xs) { - ScrollViewReader { proxy in - ScrollView { - content(proxy) - .frame(maxWidth: .infinity, alignment: .leading) - .environment(\.approvalScrollSpace, space) - .background(GeometryReader { geometry in - Color.clear.preference(key: ApprovalScrollContentKey.self, - value: [space: geometry.frame(in: .named(space))]) - }) - } - .scrollIndicators(.never) - .coordinateSpace(name: space) - .accessibilityIdentifier(identifier ?? space) - .frame(maxHeight: maxHeight) + ScrollView { + content() + .frame(maxWidth: .infinity, alignment: alignment) .background(GeometryReader { geometry in - Color.clear.preference(key: ApprovalScrollViewportKey.self, value: [space: geometry.size.height]) + Color.clear.preference(key: ApprovalScrollContentKey.self, + value: [space: geometry.frame(in: .named(space))]) }) - } - .overlay(alignment: .topTrailing) { - if overflows { indicator.offset(x: indicatorOffset) } - } - // Stays while the content overflows so the region never jumps. - if overflows { hintLabel(hint ?? appLocalized("Reached the end")) } + } + .scrollIndicators(.never) + .coordinateSpace(name: space) + .accessibilityIdentifier(identifier ?? space) + .frame(maxHeight: maxHeight) + .background(GeometryReader { geometry in + Color.clear.preference(key: ApprovalScrollViewportKey.self, value: [space: geometry.size.height]) + }) + .overlay(alignment: .topTrailing) { + if overflows { indicator.offset(x: indicatorOffset) } } .onPreferenceChange(ApprovalScrollContentKey.self) { contentFrame = $0[space] ?? .zero } .onPreferenceChange(ApprovalScrollViewportKey.self) { viewport = $0[space] ?? 0 } - .onPreferenceChange(ApprovalScrollMarkerKey.self) { markers = $0[space] ?? [] } } private var overflows: Bool { contentFrame.height > viewport + 1 && viewport > 0 } - private var hint: String? { - let hidden = markers.filter { $0.maxY > viewport + 1 } - var names: [String] = [] - for name in hidden.compactMap(\.name) where !names.contains(name) { names.append(name) } - return ApprovalOverflow.hint(unit: unit, hiddenHeight: contentFrame.maxY - viewport, - hiddenSteps: hidden.filter(\.step).count, hiddenNames: names) - } - private var indicator: some View { let overflow = max(contentFrame.height - viewport, 1) let progress = min(1, max(0, -contentFrame.minY / overflow)) @@ -97,56 +50,6 @@ struct ApprovalScrollArea: View { .allowsHitTesting(false) .accessibilityHidden(true) } - - private func hintLabel(_ text: String) -> some View { - HStack(spacing: Theme.Spacing.xs) { - if hint != nil { Image(systemName: "chevron.down").imageScale(.small) } - Text(verbatim: text).lineLimit(1).truncationMode(.tail).minimumScaleFactor(0.8) - } - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) - .frame(maxWidth: .infinity) - .accessibilityElement(children: .combine) - .accessibilityIdentifier("approval-overflow-hint") - } -} - -extension View { - /// Marks a section, named in the overflow line, or a counted step. - func approvalScrollMarker(name: String? = nil, step: Bool = false) -> some View { - modifier(ApprovalScrollMarker(name: name, step: step)) - } -} - -struct ApprovalScrollMarkerValue: Equatable { - let maxY: CGFloat - let name: String? - let step: Bool -} - -private struct ApprovalScrollMarker: ViewModifier { - let name: String? - let step: Bool - @Environment(\.approvalScrollSpace) private var space - - func body(content: Content) -> some View { - content.background(GeometryReader { geometry in - Color.clear.preference(key: ApprovalScrollMarkerKey.self, value: space.map { - [$0: [ApprovalScrollMarkerValue(maxY: geometry.frame(in: .named($0)).maxY, name: name, step: step)]] - } ?? [:]) - }) - } -} - -private struct ApprovalScrollSpaceKey: EnvironmentKey { - static let defaultValue: String? = nil -} - -private extension EnvironmentValues { - var approvalScrollSpace: String? { - get { self[ApprovalScrollSpaceKey.self] } - set { self[ApprovalScrollSpaceKey.self] = newValue } - } } // Keyed by area so nested areas never read each other's geometry. @@ -164,16 +67,9 @@ private struct ApprovalScrollViewportKey: PreferenceKey { } } -private struct ApprovalScrollMarkerKey: PreferenceKey { - static let defaultValue: [String: [ApprovalScrollMarkerValue]] = [:] - static func reduce(value: inout [String: [ApprovalScrollMarkerValue]], - nextValue: () -> [String: [ApprovalScrollMarkerValue]]) { - value.merge(nextValue()) { $0 + $1 } - } -} - -/// Stacks the card's parts and gives the one flexible part, the scrolling -/// body, whatever height remains under the cap, so the actions stay visible. +/// Stacks the card's parts like a VStack. When they would outgrow the cap, +/// the scrolling parts give up height in order, lowest value first, so the +/// actions stay visible. struct ApprovalCardLayout: Layout { let maximumHeight: CGFloat var minimumFlexibleHeight: CGFloat = 80 @@ -192,30 +88,22 @@ struct ApprovalCardLayout: Layout { } } - /// A card that would overflow first shrinks the parts that can be - /// compact, such as the icon, then lets the body scroll. private func heights(width: CGFloat, subviews: Subviews) -> [CGFloat] { - func measure(compact: Bool) -> [CGFloat] { - subviews.map { subview in - let height = compact ? subview[ApprovalCardCompactHeightKey.self] : nil - return subview.sizeThatFits(ProposedViewSize(width: width, height: height)).height - } - } - var heights = measure(compact: false) - if heights.reduce(0, +) > maximumHeight { heights = measure(compact: true) } - let excess = heights.reduce(0, +) - maximumHeight - if excess > 0, let index = subviews.firstIndex(where: { $0[ApprovalCardFlexibleKey.self] }) { - heights[index] = max(minimumFlexibleHeight, heights[index] - excess) + var heights = subviews.map { $0.sizeThatFits(ProposedViewSize(width: width, height: nil)).height } + let flexible = subviews.indices + .compactMap { index in subviews[index][ApprovalCardFlexibleKey.self].map { (index, $0) } } + .sorted { $0.1 < $1.1 } + .map(\.0) + for index in flexible { + let excess = heights.reduce(0, +) - maximumHeight + guard excess > 0 else { break } + heights[index] = max(min(heights[index], minimumFlexibleHeight), heights[index] - excess) } return heights } } +/// Marks a scrolling part of the card and the order in which it shrinks. struct ApprovalCardFlexibleKey: LayoutValueKey { - static let defaultValue = false -} - -/// The height a part takes on a card that would otherwise overflow. -struct ApprovalCardCompactHeightKey: LayoutValueKey { - static let defaultValue: CGFloat? = nil + static let defaultValue: Int? = nil } diff --git a/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift b/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift index d56dbe6..f02fedb 100644 --- a/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift +++ b/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift @@ -2,18 +2,16 @@ import AppKit import SwiftUI import AskKeyBroker -/// The approval card in the native alert layout: app icon, a title naming who -/// wants to do what to which object, fixed sections that scroll as one body -/// when the card would outgrow the screen, and stacked actions that state -/// each decision's consequence. +/// The approval card in the native alert layout: app icon, one sentence +/// naming who wants to do what with which object, one short line under it, +/// a collapsed Details section and stacked actions. Expanded details scroll +/// inside the capped card so the actions stay visible. struct FrozenAgentApprovalPrompt: View { static let width: CGFloat = 300 /// Keeps the whole card, footer included, above the Dock on the /// 768-point CI screen, where the panel's top sits under the menu bar. static let maximumHeight: CGFloat = 640 static let contentWidth = width - 2 * Theme.Spacing.lg - static let commandLineHeight = ceil(NSLayoutManager().defaultLineHeight( - for: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular))) let request: BrokerApprovalOperationRequest var trustedCredentialName: String? = nil @@ -23,8 +21,8 @@ struct FrozenAgentApprovalPrompt: View { var writeSummary: BrokerCredentialWriteSummary? = nil var organizationSummary: BrokerOrganizationSummary? = nil var revealMaterial: (@MainActor () async throws -> FrozenApprovalMaterial)? = nil - /// The approval whose system authentication was cancelled. The card then - /// offers to authenticate again for that same decision, or to deny. + /// The approval whose system authentication was cancelled. The card keeps + /// the same buttons in the same order and says that nothing was handed over. var cancelledAuthenticationDecision: BrokerApprovalDecision? = nil var detailsExpanded = false let finish: (BrokerApprovalDecision?) -> Void @@ -32,30 +30,40 @@ struct FrozenAgentApprovalPrompt: View { private var isRead: Bool { request.operation == .read } private var isOrganize: Bool { request.operation == .organize } - /// Write and organize cards are long, so their icon is smaller. - private var compact: Bool { !isRead } - var body: some View { - let _ = AppLanguage.store.resolved + /// What the card says and which buttons it offers, from the request and + /// its frozen summary. + struct Presentation { + let write: FrozenWriteSummaryContent? + let organization: FrozenOrganizationSummaryContent? + let content: ApprovalPromptContent + let buttons: [FrozenApprovalActions.Button] + } + + var presentation: Presentation { let requester = ApprovalCopy.requester(request) let write = isRead || isOrganize ? nil : writeSummary.map { FrozenWriteSummaryContent(summary: $0, requester: requester) } let organization = isOrganize ? organizationSummary.map { FrozenOrganizationSummaryContent(summary: $0, requester: requester) } : nil let content = ApprovalPromptContent(request: request, credentialName: trustedCredentialName ?? request.credentialName ?? request.targetID, - valueOnlyChange: write?.valueOnlyChange ?? false, organizationSteps: organization?.rows.count) - let primary = FrozenApprovalActions.primary(operation: request.operation, - valueOnlyChange: write?.valueOnlyChange ?? false, steps: organization?.rows.count, - destructive: organization?.isDestructive ?? false) - let topPadding = compact ? Theme.Spacing.lg : Theme.Spacing.xl - ApprovalCardLayout(maximumHeight: Self.maximumHeight - topPadding - Theme.Spacing.lg) { - icon - .padding(.bottom, Theme.Spacing.md) - .layoutValue(key: ApprovalCardCompactHeightKey.self, value: 40 + Theme.Spacing.md) - title(content.title) - cardBody(content, write: write, organization: organization) - .layoutValue(key: ApprovalCardFlexibleKey.self, value: true) + write: write, organization: organization) + let buttons = FrozenApprovalActions.buttons(operation: request.operation, timedAllowanceEnabled: timedAllowanceEnabled, + minutes: timedAllowanceMinutes, valueOnlyChange: write?.valueOnlyChange ?? false, + destructive: write?.isDestructive ?? organization?.isDestructive ?? false) + return Presentation(write: write, organization: organization, content: content, buttons: buttons) + } + + var body: some View { + let _ = AppLanguage.store.resolved + let presentation = presentation + let content = presentation.content + let expanded = showsDetails ?? detailsExpanded + ApprovalCardLayout(maximumHeight: Self.maximumHeight - Theme.Spacing.xl - Theme.Spacing.lg) { + appIcon + header(content) + .layoutValue(key: ApprovalCardFlexibleKey.self, value: 2) if cancelledAuthenticationDecision != nil { - Text(content.cancelledAuthenticationNote) + Text(appLocalized("Authentication cancelled. Nothing was handed over.")) .font(Theme.Fonts.secondary) .foregroundStyle(Theme.textSecondary) .multilineTextAlignment(.center) @@ -63,13 +71,20 @@ struct FrozenAgentApprovalPrompt: View { .padding(.top, Theme.Spacing.md) .accessibilityIdentifier("approval-authentication-cancelled") } - actions(primary, consequence: write?.consequence) + detailsToggle(expanded: expanded) + .padding(.top, Theme.Spacing.md) + if expanded { + details(content, write: presentation.write, organization: presentation.organization) + .padding(.top, Theme.Spacing.md) + .layoutValue(key: ApprovalCardFlexibleKey.self, value: 1) + } + actions(presentation.buttons) .padding(.top, Theme.Spacing.lg) footer .padding(.top, Theme.Spacing.md) } .padding(.horizontal, Theme.Spacing.lg) - .padding(.top, topPadding) + .padding(.top, Theme.Spacing.xl) .padding(.bottom, Theme.Spacing.lg) .frame(width: Self.width) .fixedSize(horizontal: false, vertical: true) @@ -77,110 +92,85 @@ struct FrozenAgentApprovalPrompt: View { .environment(\.locale, AppLanguage.store.locale) } - /// 64 points on a read card that fits; the layout shrinks it to 40 when - /// the card would overflow. Write and organize cards always use 40. - private var icon: some View { + private var appIcon: some View { Image(nsImage: AppIcon.load()) .resizable() .interpolation(.high) - .aspectRatio(1, contentMode: .fit) - .frame(maxWidth: compact ? 40 : 64, maxHeight: compact ? 40 : 64) + .frame(width: 64, height: 64) .accessibilityHidden(true) } - private func title(_ title: String) -> some View { - Text(verbatim: title) - .font(Theme.Fonts.body.bold()) - .foregroundStyle(Theme.text) - .multilineTextAlignment(.center) - .lineLimit(5) - .fixedSize(horizontal: false, vertical: true) - .help(Text(verbatim: title)) - .frame(maxWidth: .infinity) - .padding(.bottom, Theme.Spacing.md) - .accessibilityIdentifier("approval-title") - } - - private func cardBody(_ content: ApprovalPromptContent, write: FrozenWriteSummaryContent?, - organization: FrozenOrganizationSummaryContent?) -> some View { - ApprovalScrollArea(space: "approval-body", unit: isOrganize ? .steps : .sections, - identifier: isOrganize ? "approval-organization-operations" : "approval-body") { proxy in - VStack(alignment: .leading, spacing: Theme.Spacing.md) { - if isRead { - if let command = content.command { commandSection(command) } - if !content.receives.isEmpty { - ApprovalSection(title: content.receivesHeading, name: appLocalized("What the command receives"), - identifier: "approval-delivers") { - VStack(alignment: .leading, spacing: 2) { - ForEach(Array(content.receives.enumerated()), id: \.offset) { _, line in - ApprovalLineText(line: line) - } - } - } - } - } - if let purpose = content.purpose { - ApprovalSection(title: appLocalized("Stated purpose (not verified)"), name: appLocalized("Stated purpose"), - identifier: "approval-purpose") { - Text(verbatim: purpose).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) - } - } - if let organization { - FrozenOrganizationApprovalContent(content: organization) - } else if !isRead && !isOrganize { - FrozenWriteApprovalContent(operation: request.operation, content: write, - requester: content.requester, revealMaterial: revealMaterial) + /// Title and subtitle wrap freely; they scroll only when a name is too + /// long for the card. + private func header(_ content: ApprovalPromptContent) -> some View { + ApprovalScrollArea(space: "approval-header", alignment: .center) { + VStack(spacing: 0) { + Text(verbatim: content.title) + .font(Theme.Fonts.body.bold()) + .foregroundStyle(Theme.text) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, Theme.Spacing.md) + .accessibilityIdentifier("approval-title") + if let command = content.commandSummary { + (Text(appLocalized("to run")).font(Theme.Fonts.body) + + Text(verbatim: " ") + + Text(verbatim: command).font(Theme.Fonts.mono)) + .foregroundStyle(Theme.text) + .lineLimit(1) + .truncationMode(.middle) + .help(Text(verbatim: content.fullCommand ?? command)) + .padding(.top, Theme.Spacing.sm) + .accessibilityIdentifier("approval-subtitle") + } else if let subtitle = content.subtitle { + // A subtitle that names a group never cuts the name. + Text(verbatim: subtitle) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.text) + .multilineTextAlignment(.center) + .lineLimit(request.operation == .create ? nil : 2) + .fixedSize(horizontal: false, vertical: true) + .help(Text(verbatim: subtitle)) + .padding(.top, Theme.Spacing.sm) + .accessibilityIdentifier("approval-subtitle") } - details(content.detailRows, proxy: proxy) } } } - private func commandSection(_ command: String) -> some View { - ApprovalSection(title: appLocalized("Command to run"), identifier: "approval-command") { - ApprovalScrollArea(space: "approval-command-lines", unit: .lines(height: Self.commandLineHeight), - maxHeight: Self.commandLineHeight * 4 + 1, indicatorOffset: Theme.Spacing.sm - 2) { _ in - Text(verbatim: command) - .font(Theme.Fonts.mono) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) + private func detailsToggle(expanded: Bool) -> some View { + Button { + showsDetails = !expanded + } label: { + HStack(spacing: Theme.Spacing.xs) { + Text(appLocalized("Details")).font(Theme.Fonts.body) + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(Theme.Fonts.caption.weight(.semibold)) + .imageScale(.small) } - .padding(.horizontal, Theme.Spacing.sm) - .padding(.vertical, Theme.Spacing.xs) - .background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Radius.control)) + .foregroundStyle(Theme.accent) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityIdentifier("approval-details") } - private func details(_ rows: [ApprovalPromptContent.Row], proxy: ScrollViewProxy) -> some View { - let expanded = showsDetails ?? detailsExpanded - return VStack(alignment: .leading, spacing: Theme.Spacing.md) { - Button { - showsDetails = !expanded - guard !expanded else { return } - DispatchQueue.main.async { - withAnimation { proxy.scrollTo("approval-details-rows", anchor: .bottom) } - } - } label: { - HStack(spacing: Theme.Spacing.xs) { - Text(appLocalized("Details")).font(Theme.Fonts.secondary) - Image(systemName: expanded ? "chevron.down" : "chevron.right") - .font(Theme.Fonts.caption.weight(.semibold)) - .imageScale(.small) - } - .foregroundStyle(Theme.accent) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityIdentifier("approval-details") - if expanded { + private func details(_ content: ApprovalPromptContent, write: FrozenWriteSummaryContent?, + organization: FrozenOrganizationSummaryContent?) -> some View { + let rows = content.detailRows(commandFits: content.commandFits(prefix: appLocalized("to run"), + width: Self.contentWidth)) + return ApprovalScrollArea(space: "approval-details", + identifier: isOrganize ? "approval-organization-operations" : "approval-details-rows") { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Rectangle().fill(Theme.separator).frame(height: 1) Grid(alignment: .topLeading, horizontalSpacing: Theme.Spacing.md, verticalSpacing: Theme.Spacing.sm) { ForEach(Array(rows.enumerated()), id: \.offset) { _, row in GridRow { - Text(row.label) + Text(verbatim: row.label) .foregroundStyle(Theme.textSecondary) .fixedSize() Text(verbatim: row.value) - .font(row.monospaced ? Theme.Fonts.mono : Theme.Fonts.secondary) + .font(row.monospaced ? Theme.Fonts.mono : Theme.Fonts.body) .foregroundStyle(Theme.text) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) @@ -188,77 +178,49 @@ struct FrozenAgentApprovalPrompt: View { } } } - .font(Theme.Fonts.secondary) - .id("approval-details-rows") - .accessibilityIdentifier("approval-details-rows") - } - } - .approvalScrollMarker(name: appLocalized("Details")) - } - - /// Each approving button states its consequence underneath, including - /// the retry after a cancelled authentication. - private func actions(_ primary: FrozenApprovalActions.Primary, consequence: String?) -> some View { - let unavailable = isOrganize && organizationSummary == nil - let timedScope = FrozenApprovalActions.timedScope(minutes: timedAllowanceMinutes) - return VStack(spacing: Theme.Spacing.sm) { - if let retry = cancelledAuthenticationDecision { - let retriesTimed: Bool = { if case .timedAllow = retry { return true } else { return false } }() - explained(retriesTimed ? timedScope : consequence) { - ApprovalPromptButton(title: FrozenApprovalActions.retry(retry, primary: primary, minutes: timedAllowanceMinutes), - role: primary.role) { finish(retry) } - .disabled(unavailable) - .accessibilityIdentifier("approval-retry-authentication") - } - } else { - explained(consequence) { - ApprovalPromptButton(title: primary.title, role: primary.role) { finish(.once) } - .disabled(unavailable) - .accessibilityIdentifier("approval-allow-once") - } + .font(Theme.Fonts.body) if isRead, timedAllowanceEnabled { - explained(timedScope) { - ApprovalPromptButton(title: FrozenApprovalActions.timed(minutes: timedAllowanceMinutes), - role: .secondary) { finish(.timedAllow(duration: nil)) } - .accessibilityIdentifier("approval-allow-timed") - } + Text(verbatim: FrozenApprovalActions.timedScope(minutes: timedAllowanceMinutes)) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, Theme.Spacing.xs) + .accessibilityIdentifier("approval-timed-scope") + } + if let organization { + FrozenOrganizationApprovalContent(content: organization) + .padding(.top, Theme.Spacing.xs) + } else if !isRead && !isOrganize { + FrozenWriteApprovalContent(operation: request.operation, content: write, revealMaterial: revealMaterial) + .padding(.top, Theme.Spacing.xs) } } - ApprovalPromptButton(title: appLocalized("Deny"), role: .secondary) { finish(.deny) } - .accessibilityIdentifier("approval-deny") } } - private func explained(_ line: String?, @ViewBuilder button: () -> some View) -> some View { - VStack(spacing: Theme.Spacing.xs) { - button() - if let line { - Text(verbatim: line) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .accessibilityIdentifier("approval-consequence") + private func actions(_ buttons: [FrozenApprovalActions.Button]) -> some View { + VStack(spacing: Theme.Spacing.sm) { + ForEach(buttons, id: \.identifier) { button in + ApprovalPromptButton(title: button.title, role: button.role) { finish(button.decision) } + .disabled(button.decision == .once && isOrganize && organizationSummary == nil) + .help(button.help ?? "") + .accessibilityIdentifier(button.identifier) } } } private var footer: some View { - Button { finish(nil) } label: { + HStack(spacing: Theme.Spacing.xs) { TimelineView(.periodic(from: .now, by: 1)) { context in - Text(verbatim: appLocalizedFormat("Expires in %@ and nothing is handed over", - FrozenCountdown.format(deadline: expiresAt, now: context.date)) - + " · " + appLocalized("Esc to hide; decide in Pending requests before it expires")) + Text(appLocalizedFormat("Expires in %@", FrozenCountdown.format(deadline: expiresAt, now: context.date))) .monospacedDigit() - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) } + Text(verbatim: "·") + Button(appLocalized("Esc to decide later")) { finish(nil) } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) } - .buttonStyle(.plain) - .keyboardShortcut(.cancelAction) .font(Theme.Fonts.caption) .foregroundStyle(Theme.textTertiary) - .help(appLocalized("When time runs out, the request is void and nothing is handed over or written; the agent is told it expired. Esc hides this card; the request stays in Pending requests until it expires.")) - .accessibilityIdentifier("approval-decide-later") } } diff --git a/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift b/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift index 6c88099..1d803aa 100644 --- a/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift +++ b/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift @@ -1,55 +1,47 @@ import AskKeyBroker -/// Button titles that state each decision's consequence. +/// The card's stacked buttons: the approving action, the read card's timed +/// allowance, then Deny. A cancelled authentication keeps the same buttons in +/// the same order, so the default never moves to another decision. enum FrozenApprovalActions { - struct Primary: Equatable { + struct Button: Equatable { let title: String let role: ApprovalButtonRole + let decision: BrokerApprovalDecision + let identifier: String + var help: String? = nil } - static func primary(operation: BrokerApprovalOperation, valueOnlyChange: Bool = false, - steps: Int? = nil, destructive: Bool = false) -> Primary { + /// A destructive approving action is drawn in red and is never the default. + static func buttons(operation: BrokerApprovalOperation, timedAllowanceEnabled: Bool, minutes: Int = 30, + valueOnlyChange: Bool = false, destructive: Bool = false) -> [Button] { + let title: String switch operation { - case .read: return Primary(title: appLocalized("Allow Once"), role: .primary) - case .create: return Primary(title: appLocalized("Create Credential"), role: .primary) - case .modify: - return Primary(title: valueOnlyChange ? appLocalized("Replace Value") : appLocalized("Save Changes"), - role: .primary) - case .delete: return Primary(title: appLocalized("Move to Recycle Bin"), role: .destructive) - case .organize: - let title: String - switch steps { - case nil: title = appLocalized("Apply Steps") - case 1?: title = appLocalized("Apply 1 Step") - case let count?: title = appLocalizedFormat("Apply %lld Steps", count) - } - return Primary(title: title, role: destructive ? .destructive : .primary) + case .read: title = appLocalized("Allow Once") + case .create: title = appLocalized("Create credential button") + case .modify: title = valueOnlyChange ? appLocalized("Replace") : appLocalized("Change") + case .delete: title = appLocalized("Delete") + case .organize: title = appLocalized("Apply") } + var buttons = [Button(title: title, role: destructive ? .destructive : .primary, decision: .once, + identifier: "approval-allow-once")] + if operation == .read, timedAllowanceEnabled { + buttons.append(Button(title: appLocalizedFormat("Allow for %lld Minutes", minutes), role: .secondary, + decision: .timedAllow(duration: nil), identifier: "approval-allow-timed", + help: timedScope(minutes: minutes))) + } + buttons.append(Button(title: appLocalized("Deny"), role: .secondary, decision: .deny, identifier: "approval-deny")) + return buttons } - static func timed(minutes: Int) -> String { - appLocalizedFormat("Allow for %lld Minutes", minutes) - } - - /// What the timed allowance covers, stated under its button. + /// What the timed allowance covers, shown in Details and on hover. static func timedScope(minutes: Int) -> String { appLocalizedFormat("For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.", minutes) } - /// Retries the decision the user chose before cancelling authentication. - static func retry(_ decision: BrokerApprovalDecision, primary: Primary, minutes: Int) -> String { - if case .timedAllow = decision { - return appLocalizedFormat("Authenticate and %@", timed(minutes: minutes)) - } - return appLocalizedFormat("Authenticate and %@", primary.title) - } - static func titles(operation: BrokerApprovalOperation, timedAllowanceEnabled: Bool, minutes: Int = 30, - valueOnlyChange: Bool = false, steps: Int? = nil) -> [String] { - let primary = primary(operation: operation, valueOnlyChange: valueOnlyChange, steps: steps).title - if operation == .read, timedAllowanceEnabled { - return [primary, timed(minutes: minutes), appLocalized("Deny")] - } - return [primary, appLocalized("Deny")] + valueOnlyChange: Bool = false) -> [String] { + buttons(operation: operation, timedAllowanceEnabled: timedAllowanceEnabled, minutes: minutes, + valueOnlyChange: valueOnlyChange).map(\.title) } } diff --git a/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift b/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift index 650cdf5..79f0786 100644 --- a/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift @@ -12,8 +12,10 @@ struct FrozenOrganizationSummaryContent: Equatable { } let rows: [Row] - /// Deleting or merging groups cannot be undone in one step. + /// A merge can't be undone automatically. let isDestructive: Bool + /// "6 steps, one of which merges groups, affecting 3 hidden credentials". + let subtitle: String init(summary: BrokerOrganizationSummary, requester: String) { rows = summary.operations.enumerated().map { index, operation in @@ -64,12 +66,51 @@ struct FrozenOrganizationSummaryContent: Equatable { } return Row(number: index + 1, tag: tag, title: title, detail: detail) } - isDestructive = summary.operations.contains { - switch $0 { - case .deleteGroup, .mergeGroup: return true - case .move, .createGroup, .existingGroup, .renameGroup: return false + let merges = summary.operations.filter { + if case .mergeGroup = $0 { return true } else { return false } + }.count + isDestructive = merges > 0 + var subtitle = rows.count == 1 ? appLocalized("1 step") : appLocalizedFormat("%lld steps", rows.count) + if merges == 1 { + subtitle = appLocalizedFormat("%@, one of which merges groups", subtitle) + } else if merges > 1 { + subtitle = appLocalizedFormat("%1$@, %2$lld of which merge groups", subtitle, merges) + } + let hidden = Self.hiddenCredentials(summary) + if hidden == 1 { + subtitle = appLocalizedFormat("%@, affecting 1 hidden credential", subtitle) + } else if hidden > 1 { + subtitle = appLocalizedFormat("%1$@, affecting %2$lld hidden credentials", subtitle, hidden) + } + self.subtitle = subtitle + } + + /// Hidden credentials whose group the batch renames, merges away or + /// deletes. Each step's counts include credentials an earlier step already + /// moved into that group, so those are counted once. + static func hiddenCredentials(_ summary: BrokerOrganizationSummary) -> Int { + var counted: [String: Int] = [:] + var total = 0 + func leave(_ group: String, hidden: Int) -> Int { + let key = ApprovalCopy.matchKey(group) + defer { counted[key] = nil } + return max(0, hidden - counted[key, default: 0]) + } + for operation in summary.operations { + switch operation { + case .renameGroup(let from, let to, _, let hidden): + total += leave(from, hidden: hidden) + counted[ApprovalCopy.matchKey(to)] = hidden + case .mergeGroup(let from, let to, _, let hidden, _, _): + total += leave(from, hidden: hidden) + counted[ApprovalCopy.matchKey(to), default: 0] += hidden + case .deleteGroup(let name, _, let hidden): + total += leave(name, hidden: hidden) + case .move, .createGroup, .existingGroup: + break } } + return total } private static func contents(members: Int, hidden: Int) -> String { @@ -79,8 +120,7 @@ struct FrozenOrganizationSummaryContent: Equatable { } } -/// The ordered steps; the card's scrolling body counts them while any are -/// still below the visible part. +/// The ordered steps, shown in Details. struct FrozenOrganizationApprovalContent: View { let content: FrozenOrganizationSummaryContent @@ -104,7 +144,6 @@ struct FrozenOrganizationApprovalContent: View { } } .textSelection(.enabled) - .approvalScrollMarker(step: true) .accessibilityElement(children: .combine) .accessibilityIdentifier("approval-organization-step-\(row.number)") } diff --git a/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift b/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift index dbf53f8..c0dea14 100644 --- a/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift @@ -1,11 +1,12 @@ import SwiftUI import AskKeyBroker -/// The write card's fixed sections and the separately authenticated value view. +/// A write card's Details: the items with their status tags, the +/// instructions and group, what approving does, and the separately +/// authenticated value view. struct FrozenWriteApprovalContent: View { let operation: BrokerApprovalOperation var content: FrozenWriteSummaryContent? - var requester: String var revealMaterial: (@MainActor () async throws -> FrozenApprovalMaterial)? @State private var revealedMaterial: FrozenApprovalMaterial? @State private var revealing = false @@ -15,18 +16,14 @@ struct FrozenWriteApprovalContent: View { var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { if let content { - if let summary = content.changeSummary { - Text(verbatim: summary) - .font(Theme.Fonts.secondary) - .foregroundStyle(Theme.text) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .approvalScrollMarker() - .accessibilityIdentifier("approval-change-summary") - } if !content.components.isEmpty { items(content) } instructions(content) group(content) + if let consequence = content.consequence { + ApprovalSection(title: appLocalized("After you approve"), identifier: "approval-consequence") { + Text(verbatim: consequence).fixedSize(horizontal: false, vertical: true) + } + } } else if operation == .create || operation == .modify { // Without the summary the value can still be viewed before deciding. ApprovalSection(title: appLocalized("Items"), identifier: "approval-components", @@ -38,7 +35,7 @@ struct FrozenWriteApprovalContent: View { private func items(_ content: FrozenWriteSummaryContent) -> some View { let hasValues = !content.valueComponents.isEmpty - return ApprovalSection(title: content.itemsHeading, name: appLocalized("Items"), identifier: "approval-components", + return ApprovalSection(title: content.itemsHeading, identifier: "approval-components", accessory: { if hasValues { revealButton } }) { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { ForEach(Array(content.components.enumerated()), id: \.offset) { _, component in @@ -97,8 +94,8 @@ struct FrozenWriteApprovalContent: View { Text(revealedMaterial.encoding).foregroundStyle(Theme.textSecondary) } .font(Theme.Fonts.caption) - ApprovalScrollArea(space: "approval-revealed-value", unit: .lines(height: 15), maxHeight: 85, - indicatorOffset: Theme.Spacing.sm - 2) { _ in + ApprovalScrollArea(space: "approval-revealed-value", maxHeight: 85, + indicatorOffset: Theme.Spacing.sm - 2) { Text(verbatim: revealedMaterial.content) .font(Theme.Fonts.mono) .textSelection(.enabled) @@ -138,7 +135,7 @@ struct FrozenWriteApprovalContent: View { after: diffText(content.instructionsDiff?.after, placeholder: after)) if let removed = content.instructionsDiff?.removedPhrases, !removed.isEmpty { Text(appLocalizedFormat("Removed phrases: %@", - removed.map(ApprovalCopy.quote).joined(separator: appLocalized("List separator")))) + removed.map(ApprovalCopy.quoted).joined(separator: appLocalized("List separator")))) .font(Theme.Fonts.caption) .foregroundStyle(Theme.warning) .fixedSize(horizontal: false, vertical: true) diff --git a/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift b/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift index bd950ee..0db2ad9 100644 --- a/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift @@ -14,22 +14,20 @@ struct FrozenWriteSummaryContent: Equatable { /// A replaced value: the row warns that the old one is overwritten. let overwrites: Bool let byteCount: Int - /// How the change summary names this item's change; nil when unchanged. - var change: String? = nil } enum Change: Equatable { /// Create and delete show the one value involved. case current(String) case changed(before: String, after: String) - /// Modify: folded into the change summary line. + /// Modify: left out of Details. case unchanged } let operation: BrokerApprovalOperation - /// Modify only: "Changes: the value of TOKEN · Unchanged: …". - let changeSummary: String? - /// Empty when a modify leaves every item as it was. + /// The line under the card's title: where a new credential goes, or what + /// a change does. Delete says the same for every card, so it has none here. + let subtitle: String? let components: [Component] let itemCount: Int let itemsHeading: String @@ -39,9 +37,11 @@ struct FrozenWriteSummaryContent: Equatable { let group: Change let groupTag: ApprovalTag? let createsGroup: Bool - /// What approving does, stated under the primary button. + /// What approving does, stated in Details. let consequence: String? let valueOnlyChange: Bool + /// A modify that overwrites or removes values that can't be recovered. + let isDestructive: Bool var valueComponents: [Component] { components.filter(\.carriesValue) } @@ -55,7 +55,7 @@ struct FrozenWriteSummaryContent: Equatable { carriesValue: summary.operation == .create, overwrites: false, byteCount: $0.byteCount) } let componentsChanged = !modify || components.contains { $0.tag != .unchanged } - self.components = componentsChanged ? components : [] + self.components = components itemCount = (summary.operation == .delete ? summary.before : summary.after).count switch summary.operation { case .create: @@ -90,9 +90,26 @@ struct FrozenWriteSummaryContent: Equatable { ? ApprovalTextDiff(before: beforeInstructions, after: afterInstructions) : nil groupTag = modify && groupChanged ? .changed : nil createsGroup = summary.createsGroup && (summary.operation == .create || summary.operation == .modify) - changeSummary = modify ? Self.changeSummary(components, instructions: instructionsChanged, group: groupChanged) : nil valueOnlyChange = modify && componentsChanged && !instructionsChanged && !groupChanged && components.allSatisfy { $0.tag == .unchanged || $0.tag == .replaced } + isDestructive = modify && components.contains { $0.tag == .replaced || $0.tag == .removed } + switch summary.operation { + case .create: + subtitle = summary.afterGroup.map { group in + summary.createsGroup + ? appLocalizedFormat("In the new group %@", ApprovalCopy.quoted(group)) + : appLocalizedFormat("In the group %@", ApprovalCopy.quoted(group)) + } + case .modify where valueOnlyChange: + subtitle = components.filter { $0.tag == .replaced }.count == 1 + ? appLocalized("The old value can't be recovered") + : appLocalized("The old values can't be recovered") + case .modify: + subtitle = Self.changeSubtitle(components, instructions: instructionsChanged, + instructionsTextRemoved: instructionsDiff?.removedPhrases.isEmpty == false, group: groupChanged) + case .read, .delete, .organize: + subtitle = nil + } switch summary.operation { case .create: consequence = appLocalized("Its agent permission will be Ask every time, so agents need your approval for each use.") @@ -107,13 +124,12 @@ struct FrozenWriteSummaryContent: Equatable { private static func modifiedComponents(_ summary: BrokerCredentialWriteSummary, requester: String) -> [Component] { let digestsEqual = summary.beforeDigest != nil && summary.beforeDigest == summary.afterDigest - let before = Dictionary(summary.before.map { (normalized($0.name), $0) }, uniquingKeysWith: { first, _ in first }) - let afterNames = Set(summary.after.map { normalized($0.name) }) + let before = Dictionary(summary.before.map { (ApprovalCopy.matchKey($0.name), $0) }, uniquingKeysWith: { first, _ in first }) + let afterNames = Set(summary.after.map { ApprovalCopy.matchKey($0.name) }) var rows = summary.after.map { after -> Component in - guard let old = before[normalized(after.name)] else { + guard let old = before[ApprovalCopy.matchKey(after.name)] else { return Component(name: after.name, tag: .new, line: line(after), notes: [], - carriesValue: true, overwrites: false, byteCount: after.byteCount, - change: appLocalizedFormat("%@ added", after.name)) + carriesValue: true, overwrites: false, byteCount: after.byteCount) } let valueChanged: Bool if let oldDigest = old.valueDigest, let newDigest = after.valueDigest { @@ -130,8 +146,7 @@ struct FrozenWriteSummaryContent: Equatable { } return Component(name: after.name, tag: .replaced, line: ApprovalLine(after.name + " · ") + ApprovalCopy.delivery(after.delivery), - notes: notes, carriesValue: true, overwrites: true, byteCount: after.byteCount, - change: appLocalizedFormat("the value of %@", after.name)) + notes: notes, carriesValue: true, overwrites: true, byteCount: after.byteCount) } if old.delivery != after.delivery || old.masked != after.masked || old.name != after.name { var notes = [previous(line(old))] @@ -141,17 +156,14 @@ struct FrozenWriteSummaryContent: Equatable { : appLocalized("Now shown in full in Ask Key"))) } return Component(name: after.name, tag: .changed, line: line(after), notes: notes, - carriesValue: false, overwrites: false, byteCount: after.byteCount, - change: old.delivery != after.delivery - ? appLocalizedFormat("how %@ is given to programs", after.name) - : appLocalizedFormat("the settings of %@", after.name)) + carriesValue: false, overwrites: false, byteCount: after.byteCount) } return Component(name: after.name, tag: .unchanged, line: line(after), notes: [], carriesValue: false, overwrites: false, byteCount: after.byteCount) } - rows += summary.before.filter { !afterNames.contains(normalized($0.name)) }.map { + rows += summary.before.filter { !afterNames.contains(ApprovalCopy.matchKey($0.name)) }.map { Component(name: $0.name, tag: .removed, line: line($0), notes: [], carriesValue: false, - overwrites: false, byteCount: $0.byteCount, change: appLocalizedFormat("%@ removed", $0.name)) + overwrites: false, byteCount: $0.byteCount) } return rows } @@ -166,20 +178,47 @@ struct FrozenWriteSummaryContent: Equatable { return ApprovalLine(parts.first ?? "") + line + ApprovalLine(parts.dropFirst().joined()) } - /// Names what changes and what stays, item by item; never a section name - /// standing in for the items. - private static func changeSummary(_ components: [Component], instructions: Bool, group: Bool) -> String { - var changed = components.compactMap(\.change) - var unchanged = components.filter { $0.change == nil }.map(\.name) - let instructionsName = appLocalized("Change summary: instructions") - let groupName = appLocalized("group") - if instructions { changed.append(instructionsName) } else { unchanged.append(instructionsName) } - if group { changed.append(groupName) } else { unchanged.append(groupName) } - let separator = appLocalized("List separator") - if changed.isEmpty { return appLocalized("Nothing changes") } - if unchanged.isEmpty { return appLocalizedFormat("Changes: %@", changed.joined(separator: separator)) } - return appLocalizedFormat("Changes: %1$@ · Unchanged: %2$@", - changed.joined(separator: separator), unchanged.joined(separator: separator)) + /// What a change does, in the order replace, change, add, remove, then + /// which values are lost: "Replaces 1 value, changes the group; the old + /// value can't be recovered". + private static func changeSubtitle(_ components: [Component], instructions: Bool, + instructionsTextRemoved: Bool, group: Bool) -> String { + func count(_ tag: ApprovalTag) -> Int { components.filter { $0.tag == tag }.count } + let replaced = count(.replaced), changed = count(.changed), added = count(.new), removed = count(.removed) + var clauses: [String] = [] + if replaced > 0 { + clauses.append(replaced == 1 ? appLocalized("replaces 1 value") : appLocalizedFormat("replaces %lld values", replaced)) + } + if changed > 0 { + clauses.append(changed == 1 + ? appLocalized("changes the settings of 1 item") : appLocalizedFormat("changes the settings of %lld items", changed)) + } + var changes: [String] = [] + if instructions { changes.append(appLocalized("the instructions")) } + if group { changes.append(appLocalized("the group")) } + if !changes.isEmpty { + let clause = appLocalizedFormat("changes %@", ApprovalCopy.list(changes)) + clauses.append(instructionsTextRemoved + ? appLocalizedFormat("%@ (some instruction text is deleted)", clause) : clause) + } + if added > 0 { + clauses.append(added == 1 ? appLocalized("adds 1 item") : appLocalizedFormat("adds %lld items", added)) + } + if removed > 0 { + clauses.append(removed == 1 ? appLocalized("removes 1 item") : appLocalizedFormat("removes %lld items", removed)) + } + guard !clauses.isEmpty else { return appLocalized("Nothing changes") } + let text = clauses.joined(separator: appLocalized("Clause separator")) + let lost: String + switch (replaced, removed) { + case (0, 0): lost = text + case (1, 0): lost = appLocalizedFormat("%@; the old value can't be recovered", text) + case (_, 0): lost = appLocalizedFormat("%@; the old values can't be recovered", text) + case (0, 1): lost = appLocalizedFormat("%@; the removed value can't be recovered", text) + case (0, _): lost = appLocalizedFormat("%@; the removed values can't be recovered", text) + default: lost = appLocalizedFormat("%@; the old and removed values can't be recovered", text) + } + return ApprovalCopy.capitalized(lost) } private static func instructionsText(_ value: String) -> String { @@ -189,9 +228,4 @@ struct FrozenWriteSummaryContent: Equatable { private static func groupText(_ name: String?) -> String { name.map(ApprovalCopy.quoted) ?? appLocalized("Ungrouped") } - - private static func normalized(_ name: String) -> String { - name.precomposedStringWithCanonicalMapping - .folding(options: [.caseInsensitive], locale: Locale(identifier: "en_US_POSIX")) - } } diff --git a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings index 8d249ba..b4c5211 100644 --- a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings +++ b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings @@ -1,6 +1,14 @@ { "sourceLanguage": "en", "strings": { + "%1$@ and %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%1$@ and %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@和%2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ and %2$@!!]" } } + } + }, "%1$@ asked to rename %2$@ to %3$@; %3$@ already exists and is hidden from it, so the groups merge.": { "extractionState": "manual", "localizations": { @@ -9,388 +17,388 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ asked to rename %2$@ to %3$@; %3$@ already exists and is hidden from it, so the groups merge.!!]" } } } }, - "%1$@ wants to replace %2$@": { + "%1$@, %2$lld of which merge groups": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to replace %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想替换凭证%2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to replace %2$@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@, %2$lld of which merge groups" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@,其中 %2$lld 步会合并分组" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@, %2$lld of which merge groups!!]" } } } }, - "%@ added": { + "%1$@, affecting %2$lld hidden credentials": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ added" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "新增 %@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ added!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@, affecting %2$lld hidden credentials" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@,会动到 %2$lld 个隐藏的凭证" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@, affecting %2$lld hidden credentials!!]" } } } }, - "%@ removed": { + "%@ (some instruction text is deleted)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ removed" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "移除 %@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ removed!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@ (some instruction text is deleted)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@(删掉了部分说明)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ (some instruction text is deleted)!!]" } } } }, - "Below: %@": { + "%@ (not verified)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Below: %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "下面还有:%@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Below: %@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@ (not verified)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@(未核实)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ (not verified)!!]" } } } }, - "Created when you approve": { + "%@, affecting 1 hidden credential": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Created when you approve" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后创建" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Created when you approve!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@, affecting 1 hidden credential" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@,会动到 1 个隐藏的凭证" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@, affecting 1 hidden credential!!]" } } } }, - "Credential value: %@": { + "%@, one of which merges groups": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "the value of %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@的值" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!the value of %@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@, one of which merges groups" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@,其中一步会合并分组" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@, one of which merges groups!!]" } } } }, - "Esc to hide; decide in Pending requests before it expires": { + "%@; the old and removed values can't be recovered": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Esc to hide; decide in Pending requests before it expires" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "Esc 先收起,过期前可在「待处理请求」里决定" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Esc to hide; decide in Pending requests before it expires!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@; the old and removed values can't be recovered" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@,旧值和移除的值将无法找回" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@; the old and removed values can't be recovered!!]" } } } }, - "Expires in %@ and nothing is handed over": { + "%@; the old value can't be recovered": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Expires in %@ and nothing is handed over" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 后自动作废,不会交出任何东西" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Expires in %@ and nothing is handed over!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@; the old value can't be recovered" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@,旧值将无法找回" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@; the old value can't be recovered!!]" } } } }, - "For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.": { + "%@; the old values can't be recovered": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 分钟内,你这个 Mac 账户下的任何 Agent 或命令读取这个凭证都不再询问;修改或删除它仍要你批准。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@; the old values can't be recovered" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@,旧值将无法找回" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@; the old values can't be recovered!!]" } } } }, - "Hidden value": { + "%@; the removed value can't be recovered": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Hidden value" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "已隐藏的值" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Hidden value!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@; the removed value can't be recovered" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@,移除的值将无法找回" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@; the removed value can't be recovered!!]" } } } }, - "If you allow, this command receives %1$lld items from %2$@": { + "%@; the removed values can't be recovered": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "If you allow, this command receives %1$lld items from %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后这个命令会拿到%2$@里的 %1$lld 项" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!If you allow, this command receives %1$lld items from %2$@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@; the removed values can't be recovered" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@,移除的值将无法找回" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@; the removed values can't be recovered!!]" } } } }, - "If you allow, this command receives 1 item from %@": { + "%lld steps": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "If you allow, this command receives 1 item from %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后这个命令会拿到%@里的 1 项" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!If you allow, this command receives 1 item from %@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%lld steps" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "共 %lld 步" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld steps!!]" } } } }, - "If you approve, the old value is overwritten and can't be recovered.": { + "1 step": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "If you approve, the old value is overwritten and can't be recovered." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后旧值会被覆盖,无法找回。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!If you approve, the old value is overwritten and can't be recovered.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "1 step" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "共 1 步" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 step!!]" } } } }, - "Items": { + "After you approve": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Items" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "After you approve" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!After you approve!!]" } } } }, - "Items (%1$lld, new values provided by %2$@)": { + "Apply": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Items (%1$lld, new values provided by %2$@)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容(%1$lld 项,新值由 %2$@ 提供)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items (%1$lld, new values provided by %2$@)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Apply" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "执行" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Apply!!]" } } } }, - "Items (%1$lld, values provided by %2$@)": { + "Authentication cancelled. Nothing was handed over.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Items (%1$lld, values provided by %2$@)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容(%1$lld 项,值由 %2$@ 提供)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items (%1$lld, values provided by %2$@)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Authentication cancelled. Nothing was handed over." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已取消验证,未交出任何内容" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Authentication cancelled. Nothing was handed over.!!]" } } } }, - "Old value %1$@ → new value %2$@ (new value from %3$@)": { + "Change": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Old value %1$@ → new value %2$@ (new value from %3$@)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "旧值 %1$@ → 新值 %2$@(新值由 %3$@ 提供)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Old value %1$@ → new value %2$@ (new value from %3$@)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Change" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "修改" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Change!!]" } } } }, - "Removed phrases: %@": { + "Clause separator": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Removed: %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "删掉了:%@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Removed: %@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": ", " } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "," } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": ", " } } } }, - "Stated purpose": { + "Command": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Stated purpose" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "对方说的用途" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Stated purpose!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Command" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "命令" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Command!!]" } } } }, - "What the command receives": { + "Command gets": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "What the command receives" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "命令会拿到的内容" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!What the command receives!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Command gets" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "命令会拿到" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Command gets!!]" } } } }, - "When it's renamed, the group has %@.": { + "Create credential button": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "When it's renamed, the group has %@." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "改名时组里有 %@。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!When it's renamed, the group has %@.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Create" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新建" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Create!!]" } } } }, - "When it's renamed, the group is empty.": { + "Created when you approve": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "When it's renamed, the group is empty." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "改名时组里没有凭证。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!When it's renamed, the group is empty.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Created when you approve" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后创建" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Created when you approve!!]" } } } }, - "When time runs out, the request is void and nothing is handed over or written; the agent is told it expired. Esc hides this card; the request stays in Pending requests until it expires.": { + "Environment variable %@": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "When time runs out, the request is void and nothing is handed over or written; the agent is told it expired. Esc hides this card; the request stays in Pending requests until it expires." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "倒计时结束后请求作废,不会交出或写入任何东西,Agent 会收到已过期的结果。按 Esc 只是先收起卡片,请求会留在「待处理请求」里,直到过期。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!When time runs out, the request is void and nothing is handed over or written; the agent is told it expired. Esc hides this card; the request stays in Pending requests until it expires.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Environment variable %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "环境变量 %@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Environment variable %@!!]" } } } }, - "You cancelled authentication. Nothing was given to the command, and the request is still pending.": { + "Esc to decide later": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "You cancelled authentication. Nothing was given to the command, and the request is still pending." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "你取消了验证,命令没有拿到任何东西,请求仍在等待。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!You cancelled authentication. Nothing was given to the command, and the request is still pending.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Esc to decide later" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Esc 稍后处理" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Esc to decide later!!]" } } } }, - "how %@ is given to programs": { + "For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "how %@ is given to programs" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 交给程序的方式" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!how %@ is given to programs!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 分钟内,你这个 Mac 账户下的任何 Agent 或命令读取这个凭证都不再询问;修改或删除它仍要你批准。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.!!]" } } } }, - "Change summary: instructions": { + "Hidden value": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "instructions for agents" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "给 Agent 的使用说明" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!instructions for agents!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Hidden value" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已隐藏的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Hidden value!!]" } } } }, - "the settings of %@": { + "If you approve, the old value is overwritten and can't be recovered.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "the settings of %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 的设置" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!the settings of %@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "If you approve, the old value is overwritten and can't be recovered." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后旧值会被覆盖,无法找回。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!If you approve, the old value is overwritten and can't be recovered.!!]" } } } }, - "the value of %@": { + "In the group %@": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "the value of %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 的值" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!the value of %@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "In the group %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "放进%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!In the group %@!!]" } } } }, - "→ Environment variable %@": { + "In the new group %@": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "→ Environment variable %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "→ 环境变量 %@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!→ Environment variable %@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "In the new group %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "放进新分组%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!In the new group %@!!]" } } } }, - "→ Temporary file (path in %@, removed within 5 minutes)": { + "Items": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "→ Temporary file (path in %@, removed within 5 minutes)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "→ 临时文件(路径在 %@,最多 5 分钟后删除)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!→ Temporary file (path in %@, removed within 5 minutes)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Items" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items!!]" } } } }, - "Reached the end": { + "Items (%1$lld, new values provided by %2$@)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Reached the end" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "已经到底" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Reached the end!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Items (%1$lld, new values provided by %2$@)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容(%1$lld 项,新值由 %2$@ 提供)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items (%1$lld, new values provided by %2$@)!!]" } } } }, - "%1$@ (%2$lld hidden from agents)": { + "Items (%1$lld, values provided by %2$@)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ (%2$lld hidden from agents)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@(其中 %2$lld 个对 Agent 隐藏)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ (%2$lld hidden from agents)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Items (%1$lld, values provided by %2$@)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容(%1$lld 项,值由 %2$@ 提供)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items (%1$lld, values provided by %2$@)!!]" } } } }, - "%1$@ disappears and %2$@ will have %3$@.": { + "Moves to the Recycle Bin; restorable for 30 days": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ disappears and %2$@ will have %3$@." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "合并后%1$@消失,%2$@共有 %3$@。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ disappears and %2$@ will have %3$@.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Moves to the Recycle Bin; restorable for 30 days" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到回收站,30 天内可恢复" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Moves to the Recycle Bin; restorable for 30 days!!]" } } } }, - "%1$@ wants to change the credential %2$@": { + "Old value %1$@ → new value %2$@ (new value from %3$@)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to change the credential %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想修改凭证%2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to change the credential %2$@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Old value %1$@ → new value %2$@ (new value from %3$@)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "旧值 %1$@ → 新值 %2$@(新值由 %3$@ 提供)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Old value %1$@ → new value %2$@ (new value from %3$@)!!]" } } } }, - "%1$@ wants to create the credential %2$@": { + "Removed phrases: %@": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to create the credential %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想新建凭证%2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to create the credential %2$@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Removed: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "删掉了:%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Removed: %@!!]" } } } }, - "%1$@ wants to delete the credential %2$@": { + "Replace": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to delete the credential %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想删除凭证%2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to delete the credential %2$@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Replace" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "替换" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Replace!!]" } } } }, - "%1$@ wants to reorganize your groups (%2$lld steps)": { + "Stated purpose": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to reorganize your groups (%2$lld steps)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想调整分组(共 %2$lld 步)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to reorganize your groups (%2$lld steps)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Stated purpose" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "对方说的用途" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Stated purpose!!]" } } } }, - "%1$@ wants to use the credential %2$@": { + "Temporary file (path in %@, removed within 5 minutes)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to use the credential %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想使用凭证%2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to use the credential %2$@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Temporary file (path in %@, removed within 5 minutes)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "临时文件(路径在 %@,最多 5 分钟后删除)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Temporary file (path in %@, removed within 5 minutes)!!]" } } } }, - "%1$@ wants to use the credential %2$@ to run %3$@": { + "The items set to be given to programs (names are shown after you approve)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to use the credential %2$@ to run %3$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想使用凭证%2$@运行 %3$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to use the credential %2$@ to run %3$@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "The items set to be given to programs (names are shown after you approve)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "设为交给程序的所有项(具体名称批准后才能看到)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The items set to be given to programs (names are shown after you approve)!!]" } } } }, - "%@ (name provided by the requester; Ask Key can't verify it)": { + "The old value can't be recovered": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ (name provided by the requester; Ask Key can't verify it)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@(名称由请求方提供,请旨无法核实)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ (name provided by the requester; Ask Key can't verify it)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "The old value can't be recovered" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "旧值将无法找回" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The old value can't be recovered!!]" } } } }, - "%@ bytes": { + "The old values can't be recovered": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ bytes" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 字节" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ bytes!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "The old values can't be recovered" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "旧值将无法找回" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The old values can't be recovered!!]" } } } }, - "%@ wants to reorganize your groups": { + "When it's renamed, the group has %@.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ wants to reorganize your groups" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 想调整分组" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ wants to reorganize your groups!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "When it's renamed, the group has %@." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "改名时组里有 %@。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!When it's renamed, the group has %@.!!]" } } } }, - "%@ wants to reorganize your groups (1 step)": { + "When it's renamed, the group is empty.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ wants to reorganize your groups (1 step)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 想调整分组(共 1 步)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ wants to reorganize your groups (1 step)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "When it's renamed, the group is empty." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "改名时组里没有凭证。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!When it's renamed, the group is empty.!!]" } } } }, - "%lld more lines — scroll to see them": { + "%1$@ (%2$lld hidden from agents)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%lld more lines — scroll to see them" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "还有 %lld 行,向下滚动查看" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld more lines — scroll to see them!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ (%2$lld hidden from agents)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@(其中 %2$lld 个对 Agent 隐藏)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ (%2$lld hidden from agents)!!]" } } } }, - "%lld more steps — scroll to see them": { + "%1$@ disappears and %2$@ will have %3$@.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%lld more steps — scroll to see them" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "还有 %lld 步,向下滚动查看" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld more steps — scroll to see them!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ disappears and %2$@ will have %3$@." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "合并后%1$@消失,%2$@共有 %3$@。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ disappears and %2$@ will have %3$@.!!]" } } } }, - "1 byte": { + "%@ (name provided by the requester; Ask Key can't verify it)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "1 byte" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "1 字节" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 byte!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@ (name provided by the requester; Ask Key can't verify it)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@(名称由请求方提供,请旨无法核实)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ (name provided by the requester; Ask Key can't verify it)!!]" } } } }, - "1 more line — scroll to see it": { + "%@ bytes": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "1 more line — scroll to see it" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "还有 1 行,向下滚动查看" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 more line — scroll to see it!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@ bytes" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 字节" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ bytes!!]" } } } }, - "1 more step — scroll to see it": { + "1 byte": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "1 more step — scroll to see it" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "还有 1 步,向下滚动查看" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 more step — scroll to see it!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "1 byte" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "1 字节" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 byte!!]" } } } }, "A merge can't be undone automatically.": { @@ -405,42 +413,10 @@ "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Allow for %lld Minutes" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 分钟内都允许" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "允许 %lld 分钟" } }, "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Allow for %lld Minutes!!]" } } } }, - "Apply %lld Steps": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Apply %lld Steps" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "执行这 %lld 步" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Apply %lld Steps!!]" } } - } - }, - "Apply 1 Step": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Apply 1 Step" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "执行这 1 步" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Apply 1 Step!!]" } } - } - }, - "Apply Steps": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Apply Steps" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "执行这些步骤" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Apply Steps!!]" } } - } - }, - "Authenticate and %@": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Authenticate and %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "重新验证,%@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Authenticate and %@!!]" } } - } - }, "Authenticate to View": { "extractionState": "manual", "localizations": { @@ -465,30 +441,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Changed!!]" } } } }, - "Changes: %1$@ · Unchanged: %2$@": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Changes: %1$@ · Unchanged: %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "会改动:%1$@ · 不变:%2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Changes: %1$@ · Unchanged: %2$@!!]" } } - } - }, - "Changes: %@": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Changes: %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "会改动:%@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Changes: %@!!]" } } - } - }, - "Command to run": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Command to run" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "要运行的命令" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Command to run!!]" } } - } - }, "Create the group %@": { "extractionState": "manual", "localizations": { @@ -505,14 +457,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Delete the group %@!!]" } } } }, - "If you allow, this command receives": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "If you allow, this command receives" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后这个命令会拿到" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!If you allow, this command receives!!]" } } - } - }, "Instructions for agents": { "extractionState": "manual", "localizations": { @@ -561,14 +505,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Merge!!]" } } } }, - "More below — scroll to see it": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "More below — scroll to see it" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "下面还有内容,向下滚动查看" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!More below — scroll to see it!!]" } } - } - }, "Move the credential %1$@ from %2$@ to %3$@": { "extractionState": "manual", "localizations": { @@ -649,14 +585,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Rename the group %1$@ to %2$@!!]" } } } }, - "Replace Value": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Replace Value" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "替换值" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Replace Value!!]" } } - } - }, "Replaced": { "extractionState": "manual", "localizations": { @@ -681,14 +609,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": " " } } } }, - "Stated purpose (not verified)": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Stated purpose (not verified)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "对方说的用途(未核实)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Stated purpose (not verified)!!]" } } - } - }, "Its agent permission will be Ask every time, so agents need your approval for each use.": { "extractionState": "manual", "localizations": { @@ -705,14 +625,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The group is empty.!!]" } } } }, - "The items of %@ that are set to be given to programs (names are shown after you approve)": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "The items of %@ that are set to be given to programs (names are shown after you approve)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@里设为交给程序的所有项(具体名称批准后才能看到)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The items of %@ that are set to be given to programs (names are shown after you approve)!!]" } } - } - }, "This group already exists, so nothing is created or changed.": { "extractionState": "manual", "localizations": { @@ -729,6 +641,46 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Saved as is if you approve. Viewing needs another authentication; it doesn't approve.!!]" } } } }, + "adds %lld items": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "adds %lld items" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新增 %lld 项" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!adds %lld items!!]" } } + } + }, + "adds 1 item": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "adds 1 item" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新增 1 项" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!adds 1 item!!]" } } + } + }, + "changes %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "changes %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "更改%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!changes %@!!]" } } + } + }, + "changes the settings of %lld items": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "changes the settings of %lld items" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "更改 %lld 项的设置" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!changes the settings of %lld items!!]" } } + } + }, + "changes the settings of 1 item": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "changes the settings of 1 item" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "更改 1 项的设置" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!changes the settings of 1 item!!]" } } + } + }, "given to programs as a temporary file (path in %@)": { "extractionState": "manual", "localizations": { @@ -745,14 +697,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!given to programs as environment variable %@!!]" } } } }, - "group": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "group" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "分组" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!group!!]" } } - } - }, "kept in Ask Key only, never given to agents": { "extractionState": "manual", "localizations": { @@ -761,14 +705,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!kept in Ask Key only, never given to agents!!]" } } } }, - "Quoted name: %@": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "“%@”" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "「%@」" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%@”!!]" } } - } - }, "%lld credential": { "extractionState": "manual", "localizations": { @@ -5540,29 +5476,6 @@ } } }, - "Create Credential": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Create Credential" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "新建凭证" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Create Credential!!]" - } - } - } - }, "Create First Credential": { "extractionState": "extracted_with_value", "localizations": { @@ -14443,6 +14356,38 @@ } } }, + "removes %lld items": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "removes %lld items" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "移除 %lld 项" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!removes %lld items!!]" } } + } + }, + "removes 1 item": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "removes 1 item" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "移除 1 项" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!removes 1 item!!]" } } + } + }, + "replaces %lld values": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "replaces %lld values" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "替换 %lld 项的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!replaces %lld values!!]" } } + } + }, + "replaces 1 value": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "replaces 1 value" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "替换 1 项的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!replaces 1 value!!]" } } + } + }, "the AskKey CLI": { "extractionState": "extracted_with_value", "localizations": { @@ -14466,6 +14411,30 @@ } } }, + "the group": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "the group" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "分组" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!the group!!]" } } + } + }, + "the instructions": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "the instructions" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "使用说明" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!the instructions!!]" } } + } + }, + "to run": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "to run" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "用于运行" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!to run!!]" } } + } + }, "updated": { "extractionState": "extracted_with_value", "localizations": { @@ -14489,27 +14458,76 @@ } } }, - "You cancelled authentication. Nothing was changed, and the request is still pending.": { + "“%1$@” wants to change “%2$@”": { "extractionState": "manual", "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "You cancelled authentication. Nothing was changed, and the request is still pending." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "你取消了验证,没有做任何修改,请求仍在等待。" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!You cancelled authentication. Nothing was changed, and the request is still pending.!!]" - } - } + "en": { "stringUnit": { "state": "translated", "value": "“%1$@” wants to change “%2$@”" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "“%1$@”想修改“%2$@”" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%1$@” wants to change “%2$@”!!]" } } + } + }, + "“%1$@” wants to create the credential “%2$@”": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "“%1$@” wants to create the credential “%2$@”" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "“%1$@”想新建凭证“%2$@”" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%1$@” wants to create the credential “%2$@”!!]" } } + } + }, + "“%1$@” wants to delete “%2$@”": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "“%1$@” wants to delete “%2$@”" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "“%1$@”想删除“%2$@”" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%1$@” wants to delete “%2$@”!!]" } } + } + }, + "“%1$@” wants to replace %3$@ in “%2$@”": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "“%1$@” wants to replace %3$@ in “%2$@”" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "“%1$@”想替换“%2$@”的 %3$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%1$@” wants to replace %3$@ in “%2$@”!!]" } } + } + }, + "“%1$@” wants to replace the value of “%2$@”": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "“%1$@” wants to replace the value of “%2$@”" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "“%1$@”想替换“%2$@”的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%1$@” wants to replace the value of “%2$@”!!]" } } + } + }, + "“%1$@” wants to replace the values of “%2$@”": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "“%1$@” wants to replace the values of “%2$@”" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "“%1$@”想替换“%2$@”的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%1$@” wants to replace the values of “%2$@”!!]" } } + } + }, + "“%1$@” wants to use “%2$@”": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "“%1$@” wants to use “%2$@”" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "“%1$@”想使用“%2$@”" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%1$@” wants to use “%2$@”!!]" } } + } + }, + "“%1$@” wants to use “%2$@” to run %3$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "“%1$@” wants to use “%2$@” to run %3$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "“%1$@”想使用“%2$@”运行 %3$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%1$@” wants to use “%2$@” to run %3$@!!]" } } + } + }, + "“%@” wants to organize your groups": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "“%@” wants to organize your groups" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "“%@”想整理分组" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%@” wants to organize your groups!!]" } } } }, "← Back": { @@ -15076,7 +15094,7 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%@ 后自动失效" + "value": "%@ 后失效" } }, "qps-ploc": { diff --git a/Sources/AskKeyAppKit/Views/Credentials/PendingRequestPresentation.swift b/Sources/AskKeyAppKit/Views/Credentials/PendingRequestPresentation.swift index 808fb5d..16d52f3 100644 --- a/Sources/AskKeyAppKit/Views/Credentials/PendingRequestPresentation.swift +++ b/Sources/AskKeyAppKit/Views/Credentials/PendingRequestPresentation.swift @@ -1,19 +1,19 @@ import Foundation import AskKeyBroker -/// One pending request as a sentence that names the object the way the -/// approval card's title does. The command comes from the App-derived display -/// summary of the bound request, never from the helper's own claims. +/// One pending request as the approval card's title sentence. The command +/// comes from the App-derived display summary of the bound request, never +/// from the helper's own claims. struct PendingRequestPresentation: Equatable { let sentence: EmphasizedSentence init(approval: BrokerPendingApproval) { let request = approval.request let requester = ApprovalCopy.requester(request) - let credential = ApprovalCopy.quoted(approval.displayCredentialName) + let credential = approval.displayCredentialName if request.operation == .read, let command = request.display?.commandSummary, !command.isEmpty { sentence = EmphasizedSentence( - format: appLocalized("%1$@ wants to use the credential %2$@ to run %3$@"), + format: appLocalized("“%1$@” wants to use “%2$@” to run %3$@"), arguments: [requester, credential, command] ) } else if request.operation == .organize { diff --git a/Tests/AskKeyAppTests/ApprovalCardFixtures.swift b/Tests/AskKeyAppTests/ApprovalCardFixtures.swift index d42dead..b04ece8 100644 --- a/Tests/AskKeyAppTests/ApprovalCardFixtures.swift +++ b/Tests/AskKeyAppTests/ApprovalCardFixtures.swift @@ -8,8 +8,9 @@ import AskKeyBroker enum ApprovalCardFixtures { enum Card: String, CaseIterable { case readDefault, readDetails, readFile, readCancelled, readWithoutTimed, readLongCommand, readBundle - case create, createOneItem, createTwoItems, modifyMetadata, modifyValue, modifyAdded, delete - case organize, organizeExistingAndMerge, organizeTwelveSteps + case create, createOneItem, createTwoItems, createUngrouped + case modifyMetadata, modifyInstructionsAndGroup, modifyValue, modifySomeValues, modifyAdded, modifyRemoved, modifyMixed + case delete, organize, organizeExistingAndMerge, organizeTwelveSteps } static func request(_ operation: BrokerApprovalOperation, name: String? = "Staging API", @@ -63,6 +64,11 @@ enum ApprovalCardFixtures { beforeDigest: nil, afterDigest: "after", beforeUsageInstructions: nil, afterUsageInstructions: "Use for SSH to the staging host only.", beforeGroup: nil, afterGroup: "Staging", createsGroup: false) + static let createUngroupedSummary = BrokerCredentialWriteSummary(credentialName: "Deploy Host", operation: .create, + before: [], after: [component("DEPLOY_HOST", bytes: 18, .environmentVariable("DEPLOY_HOST"))], + beforeDigest: nil, afterDigest: "after", beforeUsageInstructions: nil, afterUsageInstructions: nil, + beforeGroup: nil, afterGroup: nil, createsGroup: false) + static let metadataSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, before: [token, user], after: [token, user], beforeDigest: "same", afterDigest: "same", beforeUsageInstructions: "Use for release checks.", afterUsageInstructions: "Use only for the nightly release check.", @@ -74,6 +80,27 @@ enum ApprovalCardFixtures { beforeDigest: "old", afterDigest: "new", beforeUsageInstructions: "Use for release checks.", afterUsageInstructions: "Use for release checks.", beforeGroup: "Release Tools", afterGroup: "Release Tools") + static let someValuesSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, + before: [token, user], after: [component("TOKEN", bytes: 28, .environmentVariable("RELEASE_CHECK_TOKEN"), value: "rotated"), user], + beforeDigest: "old", afterDigest: "new", beforeUsageInstructions: "Use for release checks.", + afterUsageInstructions: "Use for release checks.", beforeGroup: "Release Tools", afterGroup: "Release Tools") + + static let instructionsAndGroupSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, + before: [token, user], after: [token, user], beforeDigest: "same", afterDigest: "same", + beforeUsageInstructions: "Use for release checks.", afterUsageInstructions: "Use for release checks and smoke tests.", + beforeGroup: "Release Tools", afterGroup: "Operations") + + static let removedSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, + before: [token, user], after: [token], beforeDigest: "old", afterDigest: "new", + beforeUsageInstructions: "Use for release checks.", afterUsageInstructions: "Use for release checks.", + beforeGroup: "Release Tools", afterGroup: "Release Tools") + + static let mixedSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, + before: [component("TOKEN", bytes: 40, .environmentVariable("RELEASE_CHECK_TOKEN"))], + after: [component("TOKEN", bytes: 52, .environmentVariable("RELEASE_CHECK_TOKEN"), value: "rotated")], + beforeDigest: "old", afterDigest: "new", beforeUsageInstructions: "Use for release checks.", + afterUsageInstructions: "Use for release checks.", beforeGroup: "Release Tools", afterGroup: "Operations") + static let addedSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, before: [token, user], after: [token, user, certificate], beforeDigest: "old", afterDigest: "new", beforeUsageInstructions: "Use for release checks.", afterUsageInstructions: "Use for release checks.", @@ -135,12 +162,22 @@ enum ApprovalCardFixtures { return write(.create, createOneItemSummary, purpose: "Save the staging deploy host") case .createTwoItems: return write(.create, createTwoItemsSummary, purpose: "Save the staging SSH login") + case .createUngrouped: + return write(.create, createUngroupedSummary, purpose: "Save the staging deploy host") case .modifyMetadata: return write(.modify, metadataSummary) + case .modifyInstructionsAndGroup: + return write(.modify, instructionsAndGroupSummary) case .modifyValue: return write(.modify, valueSummary) + case .modifySomeValues: + return write(.modify, someValuesSummary) case .modifyAdded: return write(.modify, addedSummary) + case .modifyRemoved: + return write(.modify, removedSummary) + case .modifyMixed: + return write(.modify, mixedSummary) case .delete: return write(.delete, deleteSummary) case .organize: @@ -165,6 +202,14 @@ enum ApprovalCardFixtures { timedAllowanceEnabled: true, organizationSummary: summary, finish: { _ in }) } + /// The card's title, subtitle and buttons as shown, in the current language. + static func copy(_ card: Card) -> (title: String, subtitle: String?, buttons: [String]) { + let presentation = prompt(card).presentation + let content = presentation.content + let subtitle = content.commandSummary.map { appLocalized("to run") + " " + $0 } ?? content.subtitle + return (content.title, subtitle, presentation.buttons.map(\.title)) + } + /// Lays the card out and lets its scroll areas publish their geometry. static func host(_ prompt: FrozenAgentApprovalPrompt) -> NSHostingView { _ = NSApplication.shared diff --git a/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift b/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift index 1173822..fb4ad1e 100644 --- a/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift +++ b/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift @@ -4,8 +4,9 @@ import XCTest import AskKeyBroker @testable import AskKeyAppKit -/// Every card keeps its actions on a 768-point screen: the body scrolls, with -/// a visible scroll bar and an overflow line, instead of pushing them away. +/// Every card keeps its actions on a 768-point screen: expanded details, and +/// names too long for the card, scroll inside the 640-point cap instead of +/// pushing the buttons away. @MainActor final class ApprovalCardLayoutTests: AskKeyAppTestCase { private typealias Fixtures = ApprovalCardFixtures @@ -16,16 +17,36 @@ final class ApprovalCardLayoutTests: AskKeyAppTestCase { return size.height } - func testEveryCardTypeStaysWithinTheCapInBothLanguages() { - XCTAssertLessThanOrEqual(FrozenAgentApprovalPrompt.maximumHeight, 680) + private func details(_ prompt: FrozenAgentApprovalPrompt, expanded: Bool) -> FrozenAgentApprovalPrompt { + var prompt = prompt + prompt.detailsExpanded = expanded + return prompt + } + + func testEveryCardStaysWithinTheCapCollapsedAndExpanded() { + XCTAssertEqual(FrozenAgentApprovalPrompt.maximumHeight, 640) Fixtures.withLanguages { language in for card in Fixtures.Card.allCases { - XCTAssertLessThanOrEqual(height(Fixtures.prompt(card)), FrozenAgentApprovalPrompt.maximumHeight, - "\(language) \(card.rawValue)") + let collapsed = height(details(Fixtures.prompt(card), expanded: false)) + XCTAssertLessThan(collapsed, 400, "\(language) \(card.rawValue): the front is one sentence and one line") + XCTAssertLessThanOrEqual(height(details(Fixtures.prompt(card), expanded: true)), FrozenAgentApprovalPrompt.maximumHeight, + "\(language) \(card.rawValue) with Details") } } } + func testWriteCardsMatchTheReadCardLayout() { + Fixtures.withLanguages { language in + let read = height(Fixtures.prompt(.readWithoutTimed)) + for card in [Fixtures.Card.create, .delete, .modifyValue, .modifyMixed, .organize] { + let write = height(Fixtures.prompt(card)) + XCTAssertLessThanOrEqual(abs(write - read), 20, + "\(language) \(card.rawValue): same icon, sentence, line, Details link, two buttons and footer") + } + XCTAssertLessThan(height(Fixtures.prompt(.createUngrouped)), read, "\(language): no line when ungrouped") + } + } + func testExtremeContentScrollsInsideTheCapWithoutRevealingValues() { let long = String(repeating: "Guidance for the synthetic service. ", count: 110) let name = String(repeating: "Long Name ", count: 25) @@ -69,62 +90,31 @@ final class ApprovalCardLayoutTests: AskKeyAppTestCase { XCTAssertEqual(reveals, 0, "laying out a card never asks to reveal a value") } - func testCommandsOverFourLinesScrollWithAnOverflowLine() { - func read(lines: Int) -> FrozenAgentApprovalPrompt { - let command = (1...lines).map { "./step-\($0).sh" }.joined(separator: "\n") - return .init(request: Fixtures.request(.read, display: Fixtures.display(command: command)), - timedAllowanceEnabled: true, finish: { _ in }) - } - Fixtures.withLanguages { language in - let one = height(read(lines: 1)) - let four = height(read(lines: 4)) - let five = height(read(lines: 5)) - let thirty = height(read(lines: 30)) - XCTAssertEqual(four - one, 3 * FrozenAgentApprovalPrompt.commandLineHeight, accuracy: 3, - "\(language): up to four lines show in full") - XCTAssertGreaterThan(five, four, "\(language): the overflow line appears under a capped command") - XCTAssertLessThan(five - four, FrozenAgentApprovalPrompt.commandLineHeight + 6, - "\(language): the fifth line scrolls instead of growing the card") - XCTAssertEqual(thirty, five, accuracy: 1, "\(language): longer commands keep the same cap") - } - } - - func testDetailsGrowTheReadCardAndTheCancelledStateDropsTheTimedAction() { + func testDetailsExpandInPlaceAndTheCancelledStateAddsOneLine() { Fixtures.withLanguages { language in let collapsed = height(Fixtures.prompt(.readDefault)) XCTAssertGreaterThan(height(Fixtures.prompt(.readDetails)), collapsed, "\(language): Details expand in place") - let retryOnce = FrozenAgentApprovalPrompt(request: Fixtures.request(.read, display: Fixtures.display()), - timedAllowanceEnabled: true, cancelledAuthenticationDecision: .once, finish: { _ in }) - XCTAssertLessThan(height(retryOnce), collapsed, "\(language): one retry replaces two allow buttons") - XCTAssertGreaterThan(height(Fixtures.prompt(.readCancelled)), height(retryOnce), - "\(language): the timed retry keeps its scope line") + let cancelled = height(Fixtures.prompt(.readCancelled)) - collapsed + XCTAssertGreaterThan(cancelled, 10, "\(language): the gray line is added") + XCTAssertLessThan(cancelled, 2 * 16 + Theme.Spacing.md, "\(language): and no button is added or removed") XCTAssertLessThan(height(Fixtures.prompt(.readWithoutTimed)), collapsed) } } - func testShortCreateCardsFitWithoutScrolling() { - Fixtures.withLanguages { language in - for card in [Fixtures.Card.createOneItem, .createTwoItems] { - XCTAssertLessThan(height(Fixtures.prompt(card)), FrozenAgentApprovalPrompt.maximumHeight - 1, - "\(language) \(card.rawValue): one or two items, short instructions and a group fit") - } - } - } - - func testOverflowingCardsShrinkTheIconBeforeScrolling() { - func height(_ maximum: CGFloat) -> CGFloat { + func testScrollingPartsGiveUpHeightInOrder() { + func heights(_ maximum: CGFloat) -> CGFloat { NSHostingView(rootView: ApprovalCardLayout(maximumHeight: maximum) { - Color.clear.frame(minHeight: 40, idealHeight: 64, maxHeight: 64) - .layoutValue(key: ApprovalCardCompactHeightKey.self, value: 40) Color.clear.frame(height: 100) + ScrollView { Color.clear.frame(height: 200) } + .layoutValue(key: ApprovalCardFlexibleKey.self, value: 2) ScrollView { Color.clear.frame(height: 300) } - .layoutValue(key: ApprovalCardFlexibleKey.self, value: true) + .layoutValue(key: ApprovalCardFlexibleKey.self, value: 1) } .frame(width: FrozenAgentApprovalPrompt.contentWidth) .fixedSize(horizontal: false, vertical: true)).fittingSize.height } - XCTAssertEqual(height(500), 464, accuracy: 1, "a card that fits keeps the large icon") - XCTAssertEqual(height(450), 440, accuracy: 1, "a smaller icon can avoid scrolling") - XCTAssertEqual(height(300), 300, accuracy: 1, "then the body scrolls inside the cap") + XCTAssertEqual(heights(700), 600, accuracy: 1, "a card that fits keeps its natural height") + XCTAssertEqual(heights(500), 500, accuracy: 1, "Details scroll first") + XCTAssertEqual(heights(300), 300, accuracy: 1, "then the title, once Details are at their minimum") } } diff --git a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift index babd03e..b79986c 100644 --- a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift +++ b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift @@ -2,204 +2,232 @@ import XCTest import AskKeyBroker @testable import AskKeyAppKit -/// Titles, read-card sections and buttons in English and Simplified Chinese. +/// Every card is one sentence, one short line and its buttons, in English and +/// Simplified Chinese; everything else is in Details. @MainActor final class ApprovalPromptContentTests: AskKeyAppTestCase { private typealias Fixtures = ApprovalCardFixtures + private typealias Card = ApprovalCardFixtures.Card - private func plain(_ text: String) -> String { - text.replacingOccurrences(of: "\u{2060}", with: "").replacingOccurrences(of: "\u{00A0}", with: " ") + private struct Expected { + let title: String + let subtitle: String? + let buttons: [String] } - private func content(_ operation: BrokerApprovalOperation, display: BrokerApprovalOperationRequest.Display? = nil, - valueOnly: Bool = false, steps: Int? = nil) -> ApprovalPromptContent { - ApprovalPromptContent(request: Fixtures.request(operation, display: display), credentialName: "Staging API", - valueOnlyChange: valueOnly, organizationSteps: steps) - } + private let english: [Card: Expected] = [ + .readDefault: .init(title: "“Claude Code” wants to use “Staging API”", subtitle: "to run ./deploy.sh --env staging", + buttons: ["Allow Once", "Allow for 30 Minutes", "Deny"]), + .readWithoutTimed: .init(title: "“Claude Code” wants to use “Staging API”", subtitle: "to run ./deploy.sh --env staging", + buttons: ["Allow Once", "Deny"]), + .create: .init(title: "“Claude Code” wants to create the credential “Release Check”", + subtitle: "In the new group “Release Tools”", buttons: ["Create", "Deny"]), + .createTwoItems: .init(title: "“Claude Code” wants to create the credential “Staging SSH”", + subtitle: "In the group “Staging”", buttons: ["Create", "Deny"]), + .createUngrouped: .init(title: "“Claude Code” wants to create the credential “Deploy Host”", subtitle: nil, + buttons: ["Create", "Deny"]), + .modifyValue: .init(title: "“Claude Code” wants to replace the value of “Release Check”", + subtitle: "The old value can't be recovered", buttons: ["Replace", "Deny"]), + .modifySomeValues: .init(title: "“Claude Code” wants to replace TOKEN in “Release Check”", + subtitle: "The old value can't be recovered", buttons: ["Replace", "Deny"]), + .modifyMetadata: .init(title: "“Claude Code” wants to change “Release Check”", + subtitle: "Changes the instructions (some instruction text is deleted)", buttons: ["Change", "Deny"]), + .modifyInstructionsAndGroup: .init(title: "“Claude Code” wants to change “Release Check”", + subtitle: "Changes the instructions and the group", buttons: ["Change", "Deny"]), + .modifyAdded: .init(title: "“Claude Code” wants to change “Release Check”", subtitle: "Adds 1 item", + buttons: ["Change", "Deny"]), + .modifyRemoved: .init(title: "“Claude Code” wants to change “Release Check”", + subtitle: "Removes 1 item; the removed value can't be recovered", buttons: ["Change", "Deny"]), + .modifyMixed: .init(title: "“Claude Code” wants to change “Release Check”", + subtitle: "Replaces 1 value, changes the group; the old value can't be recovered", + buttons: ["Change", "Deny"]), + .delete: .init(title: "“Claude Code” wants to delete “Release Check”", + subtitle: "Moves to the Recycle Bin; restorable for 30 days", buttons: ["Delete", "Deny"]), + .organize: .init(title: "“Claude Code” wants to organize your groups", + subtitle: "4 steps, affecting 2 hidden credentials", buttons: ["Apply", "Deny"]), + .organizeExistingAndMerge: .init(title: "“Claude Code” wants to organize your groups", + subtitle: "2 steps, one of which merges groups, affecting 1 hidden credential", + buttons: ["Apply", "Deny"]), + .organizeTwelveSteps: .init(title: "“Claude Code” wants to organize your groups", + subtitle: "12 steps, affecting 3 hidden credentials", buttons: ["Apply", "Deny"]), + ] - func testTitlesNameTheObjectInBothLanguages() { - Fixtures.withLanguages { language in - let titles = [ - content(.read), content(.create), content(.modify, valueOnly: true), content(.modify), - content(.delete), content(.organize, steps: 4), content(.organize, steps: 1), content(.organize), - ].map { plain($0.title) } - if language == "en" { - XCTAssertEqual(titles, [ - "Claude Code wants to use the credential “Staging API”", - "Claude Code wants to create the credential “Staging API”", - "Claude Code wants to replace the value of “Staging API”", - "Claude Code wants to change the credential “Staging API”", - "Claude Code wants to delete the credential “Staging API”", - "Claude Code wants to reorganize your groups (4 steps)", - "Claude Code wants to reorganize your groups (1 step)", - "Claude Code wants to reorganize your groups", - ]) - } else { - XCTAssertEqual(titles, [ - "Claude Code 想使用凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese approval title. - "Claude Code 想新建凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese approval title. - "Claude Code 想替换凭证「Staging API」的值", // i18n-literal: Assert the Simplified Chinese approval title. - "Claude Code 想修改凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese approval title. - "Claude Code 想删除凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese approval title. - "Claude Code 想调整分组(共 4 步)", // i18n-literal: Assert the Simplified Chinese approval title. - "Claude Code 想调整分组(共 1 步)", // i18n-literal: Assert the Simplified Chinese approval title. - "Claude Code 想调整分组", // i18n-literal: Assert the Simplified Chinese approval title. - ]) - } - } - } + // i18n-literal: Simplified Chinese card copy from the #177 table. + private let chinese: [Card: Expected] = [ + .readDefault: .init(title: "“Claude Code”想使用“Staging API”", subtitle: "用于运行 ./deploy.sh --env staging", // i18n-literal: Chinese read card. + buttons: ["允许本次", "允许 30 分钟", "拒绝"]), // i18n-literal: Chinese read buttons. + .readWithoutTimed: .init(title: "“Claude Code”想使用“Staging API”", subtitle: "用于运行 ./deploy.sh --env staging", // i18n-literal: Chinese read card. + buttons: ["允许本次", "拒绝"]), // i18n-literal: Chinese read buttons. + .create: .init(title: "“Claude Code”想新建凭证“Release Check”", subtitle: "放进新分组“Release Tools”", // i18n-literal: Chinese create card. + buttons: ["新建", "拒绝"]), // i18n-literal: Chinese create buttons. + .createTwoItems: .init(title: "“Claude Code”想新建凭证“Staging SSH”", subtitle: "放进“Staging”", // i18n-literal: Chinese create card. + buttons: ["新建", "拒绝"]), // i18n-literal: Chinese create buttons. + .createUngrouped: .init(title: "“Claude Code”想新建凭证“Deploy Host”", subtitle: nil, // i18n-literal: Chinese create card. + buttons: ["新建", "拒绝"]), // i18n-literal: Chinese create buttons. + .modifyValue: .init(title: "“Claude Code”想替换“Release Check”的值", subtitle: "旧值将无法找回", // i18n-literal: Chinese value card. + buttons: ["替换", "拒绝"]), // i18n-literal: Chinese value buttons. + .modifySomeValues: .init(title: "“Claude Code”想替换“Release Check”的 TOKEN", subtitle: "旧值将无法找回", // i18n-literal: Chinese value card. + buttons: ["替换", "拒绝"]), // i18n-literal: Chinese value buttons. + .modifyMetadata: .init(title: "“Claude Code”想修改“Release Check”", subtitle: "更改使用说明(删掉了部分说明)", // i18n-literal: Chinese change card. + buttons: ["修改", "拒绝"]), // i18n-literal: Chinese change buttons. + .modifyInstructionsAndGroup: .init(title: "“Claude Code”想修改“Release Check”", subtitle: "更改使用说明和分组", // i18n-literal: Chinese change card. + buttons: ["修改", "拒绝"]), // i18n-literal: Chinese change buttons. + .modifyAdded: .init(title: "“Claude Code”想修改“Release Check”", subtitle: "新增 1 项", // i18n-literal: Chinese change card. + buttons: ["修改", "拒绝"]), // i18n-literal: Chinese change buttons. + .modifyRemoved: .init(title: "“Claude Code”想修改“Release Check”", subtitle: "移除 1 项,移除的值将无法找回", // i18n-literal: Chinese change card. + buttons: ["修改", "拒绝"]), // i18n-literal: Chinese change buttons. + .modifyMixed: .init(title: "“Claude Code”想修改“Release Check”", subtitle: "替换 1 项的值,更改分组,旧值将无法找回", // i18n-literal: Chinese change card. + buttons: ["修改", "拒绝"]), // i18n-literal: Chinese change buttons. + .delete: .init(title: "“Claude Code”想删除“Release Check”", subtitle: "移到回收站,30 天内可恢复", // i18n-literal: Chinese delete card. + buttons: ["删除", "拒绝"]), // i18n-literal: Chinese delete buttons. + .organize: .init(title: "“Claude Code”想整理分组", subtitle: "共 4 步,会动到 2 个隐藏的凭证", // i18n-literal: Chinese organize card. + buttons: ["执行", "拒绝"]), // i18n-literal: Chinese organize buttons. + .organizeExistingAndMerge: .init(title: "“Claude Code”想整理分组", // i18n-literal: Chinese organize card. + subtitle: "共 2 步,其中一步会合并分组,会动到 1 个隐藏的凭证", // i18n-literal: Chinese organize card. + buttons: ["执行", "拒绝"]), // i18n-literal: Chinese organize buttons. + .organizeTwelveSteps: .init(title: "“Claude Code”想整理分组", subtitle: "共 12 步,会动到 3 个隐藏的凭证", // i18n-literal: Chinese organize card. + buttons: ["执行", "拒绝"]), // i18n-literal: Chinese organize buttons. + ] - func testNamesKeepTheirQuotesAndWrapAsAWhole() { + func testEveryCardShowsItsTitleSubtitleAndButtonsInBothLanguages() { + XCTAssertEqual(Set(english.keys), Set(chinese.keys)) Fixtures.withLanguages { language in - let quoted = ApprovalCopy.quoted("Staging API") - XCTAssertEqual(plain(quoted), language == "en" ? "“Staging API”" : "「Staging API」") // i18n-literal: Chinese corner brackets. - XCTAssertFalse(quoted.contains(" "), "spaces inside a name never break") - XCTAssertEqual(quoted.filter { $0 == "\u{2060}" }.count, "Staging API".count + 1) - XCTAssertEqual(plain(ApprovalCopy.group(nil)), language == "en" ? "“Ungrouped”" : "「未分组」") // i18n-literal: Chinese ungrouped name. - // A wrapped value-only title keeps the name together with its "value of" wording. - let valueTitle = content(.modify, valueOnly: true).title - XCTAssertTrue(valueTitle.contains(language == "en" ? "f\u{2060}\u{00A0}\u{2060}“" : "」\u{2060}的\u{2060}值")) // i18n-literal: Chinese value suffix. + for (card, expected) in language == "en" ? english : chinese { + let copy = Fixtures.copy(card) + XCTAssertEqual(copy.title, expected.title, "\(language) \(card.rawValue)") + XCTAssertEqual(copy.subtitle, expected.subtitle, "\(language) \(card.rawValue)") + XCTAssertEqual(copy.buttons, expected.buttons, "\(language) \(card.rawValue)") + } } } - func testReadCardShowsTheFullCommandWhatItReceivesAndTheUnverifiedPurpose() { - let longCommand = "./deploy.sh " + (1...30).map { "--flag-\($0) value" }.joined(separator: " ") - Fixtures.withLanguages { language in - let read = content(.read, display: Fixtures.display(command: longCommand)) - XCTAssertEqual(read.command, longCommand, "the command is never shortened in the middle") - XCTAssertEqual(read.purpose, "Deploy the staging site") - XCTAssertEqual(plain(read.receivesHeading), language == "en" - ? "If you allow, this command receives 1 item from “Staging API”" - : "批准后这个命令会拿到「Staging API」里的 1 项") // i18n-literal: Assert the Simplified Chinese receives heading. - XCTAssertEqual(read.receives.map(\.plainText), [language == "en" - ? "→ Environment variable STAGING_API_TOKEN" - : "→ 环境变量 STAGING_API_TOKEN"]) // i18n-literal: Assert the Simplified Chinese delivery row. - XCTAssertEqual(read.receives.first?.segments.filter(\.code).map(\.text), ["STAGING_API_TOKEN"]) - XCTAssertEqual(read.detailRows.map(\.label), language == "en" - ? ["Runs in", "Requested by"] : ["运行目录", "请求方"]) // i18n-literal: Assert Simplified Chinese detail labels. - XCTAssertEqual(read.detailRows.map(\.value), ["~/web", language == "en" - ? "Claude Code (name provided by the requester; Ask Key can't verify it)" - : "Claude Code(名称由请求方提供,请旨无法核实)"]) // i18n-literal: Assert the Simplified Chinese requester row. - XCTAssertEqual(read.detailRows.first?.monospaced, true) - let headings = ["Command to run", "Stated purpose (not verified)", "Details"].map { appLocalized($0) } - XCTAssertEqual(headings, language == "en" - ? ["Command to run", "Stated purpose (not verified)", "Details"] : [ - "要运行的命令", "对方说的用途(未核实)", "详细信息", // i18n-literal: Assert Simplified Chinese read headings. - ]) + func testIrreversibleActionsAreRedAndNeverTheDefault() { + let destructive: Set = [.modifyValue, .modifySomeValues, .modifyRemoved, .modifyMixed, .organizeExistingAndMerge] + for card in Card.allCases { + let buttons = Fixtures.prompt(card).presentation.buttons + XCTAssertEqual(buttons.first?.role, destructive.contains(card) ? .destructive : .primary, card.rawValue) + XCTAssertEqual(buttons.first?.decision, .once) + XCTAssertEqual(buttons.last?.role, .secondary, "Deny is never red") + XCTAssertEqual(buttons.filter { $0.role == .primary }.count, destructive.contains(card) ? 0 : 1, card.rawValue) } } - func testReadDeliveryRowsCoverFilesMultiItemCredentialsAndNoDelivery() { + func testCancelledAuthenticationKeepsTheSameButtonsAndAddsOneGrayLine() { Fixtures.withLanguages { language in - let file = content(.read, display: Fixtures.display(environment: ["DEPLOY_HOST"], files: ["SSH_KEY_FILE"])) - let bundle = content(.read, display: Fixtures.display(environment: nil, files: nil)) - let nothing = content(.read, display: Fixtures.display(environment: [], files: [])) - let headings = [file, bundle, nothing].map { plain($0.receivesHeading) } - let rows = [file, bundle, nothing].map { $0.receives.map { plain($0.plainText) } } - if language == "en" { - XCTAssertEqual(headings, ["If you allow, this command receives 2 items from “Staging API”", - "If you allow, this command receives", "If you allow, this command receives"]) - XCTAssertEqual(rows, [ - ["→ Environment variable DEPLOY_HOST", "→ Temporary file (path in SSH_KEY_FILE, removed within 5 minutes)"], - ["The items of “Staging API” that are set to be given to programs (names are shown after you approve)"], - ["Nothing from this credential is given to the command"], - ]) - } else { - XCTAssertEqual(headings, ["批准后这个命令会拿到「Staging API」里的 2 项", // i18n-literal: Assert the Simplified Chinese receives heading. - "批准后这个命令会拿到", "批准后这个命令会拿到"]) // i18n-literal: Assert the Simplified Chinese receives heading. - XCTAssertEqual(rows, [ - ["→ 环境变量 DEPLOY_HOST", "→ 临时文件(路径在 SSH_KEY_FILE,最多 5 分钟后删除)"], // i18n-literal: Assert Simplified Chinese delivery rows. - ["「Staging API」里设为交给程序的所有项(具体名称批准后才能看到)"], // i18n-literal: Assert the Simplified Chinese bundle row. - ["不交给这个命令任何值"], // i18n-literal: Assert the Simplified Chinese no-delivery row. - ]) + let read = Fixtures.prompt(.readDefault).presentation.buttons + for decision in [BrokerApprovalDecision.timedAllow(duration: nil), .once] { + let cancelled = FrozenAgentApprovalPrompt(request: Fixtures.request(.read, display: Fixtures.display()), + timedAllowanceEnabled: true, cancelledAuthenticationDecision: decision, finish: { _ in }) + XCTAssertEqual(cancelled.presentation.buttons, read, "\(language): same buttons in the same order") + XCTAssertEqual(cancelled.presentation.buttons.first?.decision, .once, "the default never becomes the timed option") + XCTAssertEqual(cancelled.presentation.buttons.first?.role, .primary) } - XCTAssertFalse(rows[0].joined().contains("Staging API"), "rows never repeat the credential name") - XCTAssertTrue(content(.delete, display: Fixtures.display()).receives.isEmpty) - XCTAssertNil(content(.create, display: Fixtures.display()).command, "write cards never show a run target") + var write = Fixtures.prompt(.modifyMixed) + let buttons = write.presentation.buttons + write.cancelledAuthenticationDecision = .once + XCTAssertEqual(write.presentation.buttons, buttons) + XCTAssertEqual(appLocalized("Authentication cancelled. Nothing was handed over."), language == "en" + ? "Authentication cancelled. Nothing was handed over." : "已取消验证,未交出任何内容") // i18n-literal: Chinese cancelled note. } } - func testButtonsStateTheirConsequenceInBothLanguages() { + func testReadDetailsHoldTheRequesterPurposeDeliveryAndTimedScope() { Fixtures.withLanguages { language in - let titles = [ - FrozenApprovalActions.titles(operation: .read, timedAllowanceEnabled: true), - FrozenApprovalActions.titles(operation: .read, timedAllowanceEnabled: false), - FrozenApprovalActions.titles(operation: .create, timedAllowanceEnabled: true), - FrozenApprovalActions.titles(operation: .modify, timedAllowanceEnabled: true), - FrozenApprovalActions.titles(operation: .modify, timedAllowanceEnabled: true, valueOnlyChange: true), - FrozenApprovalActions.titles(operation: .delete, timedAllowanceEnabled: true), - FrozenApprovalActions.titles(operation: .organize, timedAllowanceEnabled: true, steps: 4), - FrozenApprovalActions.titles(operation: .organize, timedAllowanceEnabled: true, steps: 1), - ] + let read = ApprovalPromptContent(request: Fixtures.request(.read, display: Fixtures.display()), credentialName: "Staging API") + XCTAssertTrue(read.commandFits(prefix: appLocalized("to run"), width: FrozenAgentApprovalPrompt.contentWidth)) + let rows = read.detailRows(commandFits: true) if language == "en" { - XCTAssertEqual(titles, [ - ["Allow Once", "Allow for 30 Minutes", "Deny"], ["Allow Once", "Deny"], - ["Create Credential", "Deny"], ["Save Changes", "Deny"], ["Replace Value", "Deny"], - ["Move to Recycle Bin", "Deny"], ["Apply 4 Steps", "Deny"], ["Apply 1 Step", "Deny"], - ]) + XCTAssertEqual(rows.map(\.label), ["Command gets", "Runs in", "Requested by", "Stated purpose"]) + XCTAssertEqual(rows.map(\.value), ["Environment variable STAGING_API_TOKEN", "~/web", + "Claude Code (name provided by the requester; Ask Key can't verify it)", + "Deploy the staging site (not verified)"]) XCTAssertEqual(FrozenApprovalActions.timedScope(minutes: 30), "For 30 minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.") } else { - XCTAssertEqual(titles, [ - ["允许本次", "30 分钟内都允许", "拒绝"], ["允许本次", "拒绝"], // i18n-literal: Assert Simplified Chinese read buttons. - ["新建凭证", "拒绝"], ["保存修改", "拒绝"], ["替换值", "拒绝"], // i18n-literal: Assert Simplified Chinese write buttons. - ["移到回收站", "拒绝"], ["执行这 4 步", "拒绝"], ["执行这 1 步", "拒绝"], // i18n-literal: Assert Simplified Chinese write buttons. - ]) + XCTAssertEqual(rows.map(\.label), ["命令会拿到", "运行目录", "请求方", "对方说的用途"]) // i18n-literal: Chinese detail labels. + XCTAssertEqual(rows.map(\.value), ["环境变量 STAGING_API_TOKEN", "~/web", // i18n-literal: Chinese detail values. + "Claude Code(名称由请求方提供,请旨无法核实)", "Deploy the staging site(未核实)"]) // i18n-literal: Chinese detail values. XCTAssertEqual(FrozenApprovalActions.timedScope(minutes: 30), - "30 分钟内,你这个 Mac 账户下的任何 Agent 或命令读取这个凭证都不再询问;修改或删除它仍要你批准。") // i18n-literal: Assert the Simplified Chinese timed scope. + "30 分钟内,你这个 Mac 账户下的任何 Agent 或命令读取这个凭证都不再询问;修改或删除它仍要你批准。") // i18n-literal: Chinese timed scope. } - XCTAssertEqual(FrozenApprovalActions.primary(operation: .delete).role, .destructive) - XCTAssertEqual(FrozenApprovalActions.primary(operation: .organize, steps: 2, destructive: true).role, .destructive) - XCTAssertEqual(FrozenApprovalActions.primary(operation: .organize, steps: 2).role, .primary) - XCTAssertEqual(FrozenApprovalActions.primary(operation: .create).role, .primary) + XCTAssertEqual(rows.map(\.monospaced), [false, true, false, false]) } } - func testCancelledAuthenticationRetriesTheOriginalChoice() { + func testReadDetailsDescribeFilesBundlesAndLongCommands() { Fixtures.withLanguages { language in - let read = FrozenApprovalActions.primary(operation: .read) - let trash = FrozenApprovalActions.primary(operation: .delete) - let retries = [ - FrozenApprovalActions.retry(.once, primary: read, minutes: 30), - FrozenApprovalActions.retry(.timedAllow(duration: nil), primary: read, minutes: 30), - FrozenApprovalActions.retry(.once, primary: trash, minutes: 30), + func receives(_ display: BrokerApprovalOperationRequest.Display) -> String { + ApprovalPromptContent.receives(display) + } + let values = [ + receives(Fixtures.display(environment: ["DEPLOY_HOST"], files: ["SSH_KEY_FILE"])), + receives(Fixtures.display(environment: nil, files: nil)), + receives(Fixtures.display(environment: [], files: [])), ] - XCTAssertEqual(retries, language == "en" - ? ["Authenticate and Allow Once", "Authenticate and Allow for 30 Minutes", "Authenticate and Move to Recycle Bin"] - : ["重新验证,允许本次", "重新验证,30 分钟内都允许", "重新验证,移到回收站"]) // i18n-literal: Assert Simplified Chinese retry buttons. - XCTAssertEqual(content(.read).cancelledAuthenticationNote, language == "en" - ? "You cancelled authentication. Nothing was given to the command, and the request is still pending." - : "你取消了验证,命令没有拿到任何东西,请求仍在等待。") // i18n-literal: Assert the Simplified Chinese cancelled note. + XCTAssertEqual(values, language == "en" ? [ + "Environment variable DEPLOY_HOST\nTemporary file (path in SSH_KEY_FILE, removed within 5 minutes)", + "The items set to be given to programs (names are shown after you approve)", + "Nothing from this credential is given to the command", + ] : [ + "环境变量 DEPLOY_HOST\n临时文件(路径在 SSH_KEY_FILE,最多 5 分钟后删除)", // i18n-literal: Chinese delivery rows. + "设为交给程序的所有项(具体名称批准后才能看到)", // i18n-literal: Chinese bundle row. + "不交给这个命令任何值", // i18n-literal: Chinese no-delivery row. + ]) + let command = "./deploy.sh " + (1...30).map { "--flag-\($0) value" }.joined(separator: " ") + let long = ApprovalPromptContent(request: Fixtures.request(.read, display: Fixtures.display(command: command)), + credentialName: "Staging API") + XCTAssertFalse(long.commandFits(prefix: appLocalized("to run"), width: FrozenAgentApprovalPrompt.contentWidth)) + let rows = long.detailRows(commandFits: false) + XCTAssertEqual(rows.first?.label, language == "en" ? "Command" : "命令") // i18n-literal: Chinese command label. + XCTAssertEqual(rows.first?.value, command, "Details show the whole command") + XCTAssertTrue(rows.first?.monospaced == true) + XCTAssertNil(ApprovalPromptContent(request: Fixtures.request(.create, display: Fixtures.display()), + credentialName: "Staging API").commandSummary, "write cards never show a run target") } } - func testFooterAndOverflowCopyInBothLanguages() { + func testNamesAreQuotedInBothLanguagesAndNeverShortened() { + let name = String(repeating: "Long Credential Name ", count: 12) Fixtures.withLanguages { language in - let footer = appLocalizedFormat("Expires in %@ and nothing is handed over", "4:59") + " · " - + appLocalized("Esc to hide; decide in Pending requests before it expires") - let hints = [ - ApprovalOverflow.hint(unit: .steps, hiddenHeight: 120, hiddenSteps: 3, hiddenNames: [appLocalized("Details")]), - ApprovalOverflow.hint(unit: .steps, hiddenHeight: 20, hiddenSteps: 1), - ApprovalOverflow.hint(unit: .steps, hiddenHeight: 20, hiddenNames: [appLocalized("Details")]), - ApprovalOverflow.hint(unit: .lines(height: 15), hiddenHeight: 46), - ApprovalOverflow.hint(unit: .lines(height: 15), hiddenHeight: 10), - ApprovalOverflow.hint(unit: .sections, hiddenHeight: 80, - hiddenNames: [appLocalized("Group"), appLocalized("Details")]), - ApprovalOverflow.hint(unit: .sections, hiddenHeight: 8), - ApprovalOverflow.hint(unit: .steps, hiddenHeight: 0.5), - ] - if language == "en" { - XCTAssertEqual(footer, "Expires in 4:59 and nothing is handed over · Esc to hide; decide in Pending requests before it expires") - XCTAssertEqual(appLocalized("Pending requests"), "Pending requests", "the footer uses the sidebar's item name") - XCTAssertEqual(hints, ["3 more steps — scroll to see them", "1 more step — scroll to see it", "Below: Details", - "3 more lines — scroll to see them", "1 more line — scroll to see it", - "Below: Group, Details", "More below — scroll to see it", nil]) - } else { - XCTAssertEqual(footer, "4:59 后自动作废,不会交出任何东西 · Esc 先收起,过期前可在「待处理请求」里决定") // i18n-literal: Assert the Simplified Chinese footer. - XCTAssertEqual(hints, ["还有 3 步,向下滚动查看", "还有 1 步,向下滚动查看", "下面还有:详细信息", // i18n-literal: Assert Simplified Chinese overflow hints. - "还有 3 行,向下滚动查看", "还有 1 行,向下滚动查看", // i18n-literal: Assert Simplified Chinese overflow hints. - "下面还有:分组、详细信息", "下面还有内容,向下滚动查看", nil]) // i18n-literal: Assert Simplified Chinese overflow hints. + XCTAssertEqual(ApprovalCopy.quoted("Staging API"), "“Staging API”") + XCTAssertEqual(ApprovalCopy.group(nil), language == "en" ? "“Ungrouped”" : "“未分组”") // i18n-literal: Chinese ungrouped name. + let title = ApprovalPromptContent(request: Fixtures.request(.read, name: name), credentialName: name).title + XCTAssertTrue(title.contains("“" + name + "”")) + } + } + + func testFooterInBothLanguages() { + Fixtures.withLanguages { language in + let footer = appLocalizedFormat("Expires in %@", "4:11") + " · " + appLocalized("Esc to decide later") + XCTAssertEqual(footer, language == "en" ? "Expires in 4:11 · Esc to decide later" : "4:11 后失效 · Esc 稍后处理") // i18n-literal: Chinese footer. + XCTAssertEqual(appLocalized("Details"), language == "en" ? "Details" : "详细信息") // i18n-literal: Chinese Details link. + } + } + + func testPendingListSaysTheCardTitle() { + Fixtures.withLanguages { language in + let sentences = [ + Fixtures.request(.read, display: Fixtures.display()), Fixtures.request(.read), + Fixtures.request(.create), Fixtures.request(.modify), Fixtures.request(.delete), Fixtures.request(.organize), + ].map { request in + PendingRequestPresentation(approval: BrokerPendingApproval(requestID: "request", capability: "capability", + request: request, trustedCredentialName: "Staging API")).sentence.plainText } + XCTAssertEqual(sentences, language == "en" ? [ + "“Claude Code” wants to use “Staging API” to run ./deploy.sh --env staging", + "“Claude Code” wants to use “Staging API”", + "“Claude Code” wants to create the credential “Staging API”", + "“Claude Code” wants to change “Staging API”", + "“Claude Code” wants to delete “Staging API”", + "“Claude Code” wants to organize your groups", + ] : [ + "“Claude Code”想使用“Staging API”运行 ./deploy.sh --env staging", // i18n-literal: Chinese pending sentence. + "“Claude Code”想使用“Staging API”", // i18n-literal: Chinese pending sentence. + "“Claude Code”想新建凭证“Staging API”", // i18n-literal: Chinese pending sentence. + "“Claude Code”想修改“Staging API”", // i18n-literal: Chinese pending sentence. + "“Claude Code”想删除“Staging API”", // i18n-literal: Chinese pending sentence. + "“Claude Code”想整理分组", // i18n-literal: Chinese pending sentence. + ]) } } } diff --git a/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift b/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift index 6cd69b5..0438240 100644 --- a/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift +++ b/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift @@ -4,8 +4,9 @@ import AskKeyBroker @testable import AskKeyAppKit @testable import AskKeyVault -/// Write cards: plain-language item rows with exact status tags, change -/// summaries that name what changes, and what happens after approval. +/// Write cards: the subtitle that says what a change does, and in Details +/// plain-language item rows with exact status tags and what happens after +/// approval. @MainActor final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { private typealias Fixtures = ApprovalCardFixtures @@ -50,24 +51,25 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { XCTAssertEqual(create.components.first?.line.segments.filter(\.code).map(\.text), ["RELEASE_CHECK_TOKEN"]) XCTAssertEqual(create.valueComponents.map(\.name), ["TOKEN", "CERT", "RECOVERY_CODE"]) XCTAssertEqual(create.instructions, .current("Use only for the release smoke check. Keep values out of logs.")) - XCTAssertEqual(create.group.plain, .current(language == "en" ? "“Release Tools”" : "「Release Tools」")) // i18n-literal: Chinese corner brackets. + XCTAssertEqual(create.group, .current("“Release Tools”")) XCTAssertTrue(create.createsGroup) - XCTAssertNil(create.changeSummary) + XCTAssertFalse(create.isDestructive) } } - func testMetadataOnlyChangeNamesWhatChangesAndFoldsTheItems() { + func testMetadataOnlyChangeTagsEveryItemUnchanged() { Fixtures.withLanguages { language in let modify = content(Fixtures.metadataSummary) - XCTAssertTrue(modify.components.isEmpty, "unchanged items fold into the summary line") + XCTAssertEqual(modify.components.map(\.tag), [.unchanged, .unchanged], "Details list every item with its tag") XCTAssertTrue(modify.valueComponents.isEmpty) XCTAssertEqual(modify.instructionsTag, .changed) XCTAssertEqual(modify.group, .unchanged) XCTAssertFalse(modify.valueOnlyChange) + XCTAssertFalse(modify.isDestructive) XCTAssertNil(modify.consequence) - XCTAssertEqual(modify.changeSummary, language == "en" - ? "Changes: instructions for agents · Unchanged: TOKEN, USER, group" - : "会改动:给 Agent 的使用说明 · 不变:TOKEN、USER、分组") // i18n-literal: Assert the Simplified Chinese change summary. + XCTAssertEqual(modify.subtitle, language == "en" + ? "Changes the instructions (some instruction text is deleted)" + : "更改使用说明(删掉了部分说明)") // i18n-literal: Assert the Simplified Chinese change subtitle. } } @@ -75,6 +77,7 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { Fixtures.withLanguages { language in let modify = content(Fixtures.valueSummary) XCTAssertTrue(modify.valueOnlyChange) + XCTAssertTrue(modify.isDestructive) XCTAssertEqual(modify.components.map(\.tag), [.replaced]) XCTAssertEqual(modify.components.map(\.overwrites), [true]) XCTAssertEqual(modify.valueComponents.map(\.byteCount), [52]) @@ -83,13 +86,13 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { if language == "en" { XCTAssertEqual(lines(modify), ["TOKEN · given to programs as environment variable RELEASE_CHECK_TOKEN"]) XCTAssertEqual(replacement, ["Old value 40 bytes → new value 52 bytes (new value from Claude Code)"]) - XCTAssertEqual(modify.changeSummary, "Changes: the value of TOKEN · Unchanged: instructions for agents, group") + XCTAssertEqual(modify.subtitle, "The old value can't be recovered") XCTAssertEqual(modify.itemsHeading, "Items (1, new values provided by Claude Code)") XCTAssertEqual(appLocalized("If you approve, the old value is overwritten and can't be recovered."), "If you approve, the old value is overwritten and can't be recovered.") } else { XCTAssertEqual(replacement, ["旧值 40 字节 → 新值 52 字节(新值由 Claude Code 提供)"]) // i18n-literal: Assert the Simplified Chinese replacement row. - XCTAssertEqual(modify.changeSummary, "会改动:TOKEN 的值 · 不变:给 Agent 的使用说明、分组") // i18n-literal: Assert the Simplified Chinese change summary. + XCTAssertEqual(modify.subtitle, "旧值将无法找回") // i18n-literal: Assert the Simplified Chinese value subtitle. XCTAssertEqual(modify.itemsHeading, "凭证内容(1 项,新值由 Claude Code 提供)") // i18n-literal: Assert the Simplified Chinese items heading. XCTAssertEqual(appLocalized("If you approve, the old value is overwritten and can't be recovered."), "批准后旧值会被覆盖,无法找回。") // i18n-literal: Assert the Simplified Chinese overwrite line. @@ -103,9 +106,8 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { XCTAssertEqual(modify.components.map(\.tag), [.unchanged, .unchanged, .new]) XCTAssertEqual(modify.valueComponents.map(\.name), ["CERT"], "only the new value is listed") XCTAssertFalse(modify.valueOnlyChange) - XCTAssertEqual(modify.changeSummary, language == "en" - ? "Changes: CERT added · Unchanged: TOKEN, USER, instructions for agents, group" - : "会改动:新增 CERT · 不变:TOKEN、USER、给 Agent 的使用说明、分组") // i18n-literal: Assert the Simplified Chinese change summary. + XCTAssertFalse(modify.isDestructive, "adding an item loses nothing") + XCTAssertEqual(modify.subtitle, language == "en" ? "Adds 1 item" : "新增 1 项") // i18n-literal: Assert the Simplified Chinese change subtitle. XCTAssertEqual([ApprovalTag.unchanged, .new, .replaced].map(\.title), language == "en" ? ["Unchanged", "New", "Replaced"] : ["不变", "新增", "替换"]) // i18n-literal: Assert Simplified Chinese tags. } @@ -120,16 +122,16 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { let rotated = Fixtures.component("TOKEN", bytes: 28, .environmentVariable("RELEASE_CHECK_TOKEN"), value: "rotated") XCTAssertEqual(content(modification(before: [token, user], after: [rotated, user])).components.map(\.tag), [.replaced, .unchanged], "same size and delivery, different value") - XCTAssertTrue(content(modification(before: [token, user], after: [token, user], - beforeDigest: "same", afterDigest: "same")).components.isEmpty) + XCTAssertEqual(content(modification(before: [token, user], after: [token, user], + beforeDigest: "same", afterDigest: "same")).components.map(\.tag), [.unchanged, .unchanged]) let moved = Fixtures.component("TOKEN", bytes: 28, .temporaryFile("RELEASE_CHECK_FILE")) let changed = content(modification(before: [token, user], after: [moved])) XCTAssertEqual(changed.components.map(\.tag), [.changed, .removed]) XCTAssertEqual(changed.components.first?.notes.map(\.plainText), ["Before: TOKEN · 28 bytes · given to programs as environment variable RELEASE_CHECK_TOKEN"]) XCTAssertTrue(changed.valueComponents.isEmpty, "a new delivery for the same value adds no value to view") - XCTAssertEqual(changed.changeSummary, - "Changes: how TOKEN is given to programs, USER removed · Unchanged: instructions for agents, group") + XCTAssertEqual(changed.subtitle, "Changes the settings of 1 item, removes 1 item; the removed value can't be recovered") + XCTAssertTrue(changed.isDestructive) // Summaries without per-item digests claim a replacement rather than "unchanged". let legacy = BrokerCredentialComponentSummary(name: "TOKEN", payloadKind: .text, byteCount: 28, delivery: .environmentVariable("RELEASE_CHECK_TOKEN"), masked: true) @@ -171,8 +173,8 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { XCTAssertEqual(chinese.after.filter { $0.kind == .added }.map(\.text), ["夜间"]) // i18n-literal: Synthetic Chinese diff. XCTAssertEqual(chinese.removedPhrases, ["不要写进日志"]) // i18n-literal: Synthetic Chinese diff. Fixtures.withLanguages { language in - XCTAssertEqual(appLocalizedFormat("Removed phrases: %@", ApprovalCopy.quote("Keep values out of logs.")), language == "en" - ? "Removed: “Keep values out of logs.”" : "删掉了:「Keep values out of logs.」") // i18n-literal: Assert the Simplified Chinese removed line. + XCTAssertEqual(appLocalizedFormat("Removed phrases: %@", ApprovalCopy.quoted("Keep values out of logs.")), language == "en" + ? "Removed: “Keep values out of logs.”" : "删掉了:“Keep values out of logs.”") // i18n-literal: Assert the Simplified Chinese removed line. let modify = content(Fixtures.metadataSummary) XCTAssertEqual(modify.instructionsDiff?.removedPhrases, ["checks"]) XCTAssertEqual(modify.instructionsDiff?.after.filter { $0.kind == .added }.map(\.text), ["only ", "the nightly ", "check"]) @@ -186,6 +188,8 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { XCTAssertTrue(delete.valueComponents.isEmpty) XCTAssertEqual(delete.itemsHeading, language == "en" ? "Items (2)" : "凭证内容(2 项)") // i18n-literal: Assert the Simplified Chinese items heading. XCTAssertEqual(delete.instructions, .current("Use for release checks.")) + XCTAssertNil(delete.subtitle, "the card states the Recycle Bin for every delete") + XCTAssertFalse(delete.isDestructive, "a deleted credential can be restored") XCTAssertEqual(delete.consequence, language == "en" ? "It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile." : "凭证会移到回收站保留 30 天,期间可以在回收站恢复,之后永久删除。这段时间里 Agent 不能使用,也看不到它。") // i18n-literal: Assert the Simplified Chinese delete statement. @@ -205,21 +209,41 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { func testNothingChangedIsSaidPlainly() { Fixtures.withLanguages { language in let same = content(modification(before: [Fixtures.token], after: [Fixtures.token], beforeDigest: "same", afterDigest: "same")) - XCTAssertEqual(same.changeSummary, language == "en" ? "Nothing changes" : "不会改动任何内容") // i18n-literal: Assert the Simplified Chinese no-change summary. + XCTAssertEqual(same.subtitle, language == "en" ? "Nothing changes" : "不会改动任何内容") // i18n-literal: Assert the Simplified Chinese no-change subtitle. } } -} -private extension FrozenWriteSummaryContent.Change { - /// The displayed text without the word joiners that keep names whole. - var plain: Self { - func clean(_ text: String) -> String { - text.replacingOccurrences(of: "\u{2060}", with: "").replacingOccurrences(of: "\u{00A0}", with: " ") + func testChangeSubtitlesListEveryKindOfChangeAndWhatIsLost() { + let previous = AppLanguage.current + defer { AppLanguage.current = previous } + let rotated = Fixtures.component("TOKEN", bytes: 28, .environmentVariable("RELEASE_CHECK_TOKEN"), value: "rotated") + let rotatedUser = Fixtures.component("USER", bytes: 12, .environmentVariable("RELEASE_USER"), value: "rotated") + let moved = Fixtures.component("USER", bytes: 12, .temporaryFile("RELEASE_USER_FILE")) + let everything = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, + before: [Fixtures.token, Fixtures.user, Fixtures.recovery], after: [rotated, moved, Fixtures.certificate], + beforeDigest: "old", afterDigest: "new", beforeUsageInstructions: "Use for release checks. Keep values out of logs.", + afterUsageInstructions: "Use for release checks.", beforeGroup: nil, afterGroup: "Operations", createsGroup: true) + let twoValues = modification(before: [Fixtures.token, Fixtures.user], after: [rotated, rotatedUser]) + for language in ["en", "zh-Hans"] { + AppLanguage.current = language + XCTAssertEqual(content(everything).subtitle, language == "en" + ? "Replaces 1 value, changes the settings of 1 item, changes the instructions and the group (some instruction text is deleted), adds 1 item, removes 1 item; the old and removed values can't be recovered" + : "替换 1 项的值,更改 1 项的设置,更改使用说明和分组(删掉了部分说明),新增 1 项,移除 1 项,旧值和移除的值将无法找回") // i18n-literal: Assert the Simplified Chinese change subtitle. + XCTAssertEqual(content(twoValues).subtitle, language == "en" + ? "The old values can't be recovered" : "旧值将无法找回") // i18n-literal: Assert the Simplified Chinese value subtitle. } - switch self { - case .current(let value): return .current(clean(value)) - case .changed(let before, let after): return .changed(before: clean(before), after: clean(after)) - case .unchanged: return .unchanged + XCTAssertTrue(content(everything).isDestructive) + XCTAssertFalse(content(everything).valueOnlyChange) + XCTAssertTrue(content(twoValues).valueOnlyChange) + } + + func testCreateSubtitleNamesTheGroupAndOmitsUngrouped() { + Fixtures.withLanguages { language in + XCTAssertEqual(content(Fixtures.createSummary).subtitle, language == "en" + ? "In the new group “Release Tools”" : "放进新分组“Release Tools”") // i18n-literal: Assert the Simplified Chinese create subtitle. + XCTAssertEqual(content(Fixtures.createTwoItemsSummary).subtitle, language == "en" + ? "In the group “Staging”" : "放进“Staging”") // i18n-literal: Assert the Simplified Chinese create subtitle. + XCTAssertNil(content(Fixtures.createUngroupedSummary).subtitle) } } } diff --git a/Tests/AskKeyAppTests/LocalizationUnificationTests.swift b/Tests/AskKeyAppTests/LocalizationUnificationTests.swift index 6de54ad..0ad5637 100644 --- a/Tests/AskKeyAppTests/LocalizationUnificationTests.swift +++ b/Tests/AskKeyAppTests/LocalizationUnificationTests.swift @@ -183,12 +183,11 @@ final class LocalizationUnificationTests: AskKeyAppTestCase { "Ask: Agents wait for your approval each time.": "Ask every time: Agents wait for your approval each time.", "New credentials default to Ask.": "New credentials default to Ask every time.", "Component list separator": ", ", - "Quoted name: %@": "“%@”", "Sentence separator": " ", + "Clause separator": ", ", "New group tag": "New group", - "Credential value: %@": "the value of %@", + "Create credential button": "Create", "Removed phrases: %@": "Removed: %@", - "Change summary: instructions": "instructions for agents", ] XCTAssertEqual(english, allowedAliases[key], "English value must match the key or a declared alias: \(key)") } diff --git a/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift b/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift index 0c4ba9d..9f05d6a 100644 --- a/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift +++ b/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift @@ -2,25 +2,22 @@ import XCTest import AskKeyBroker @testable import AskKeyAppKit -/// Organize cards: steps as sentences, no-change and merge tags, hidden -/// counts and the step-counting button in both languages. +/// Organize cards: the subtitle that counts steps, merges and hidden +/// credentials, and in Details the steps as sentences with no-change and +/// merge tags, in both languages. @MainActor final class OrganizationApprovalContentTests: AskKeyAppTestCase { private typealias Fixtures = ApprovalCardFixtures - private func plain(_ text: String?) -> String? { - text?.replacingOccurrences(of: "\u{2060}", with: "").replacingOccurrences(of: "\u{00A0}", with: " ") - } - func testRegularStepsReadAsSentences() { Fixtures.withLanguages { language in let content = FrozenOrganizationSummaryContent(summary: Fixtures.regularOrganization, requester: "Claude Code") XCTAssertEqual(content.rows.map(\.number), [1, 2, 3, 4]) XCTAssertEqual(content.rows.map(\.tag), [nil, nil, nil, nil]) XCTAssertNil(content.rows[0].detail, "a new group needs no second line") - XCTAssertTrue(content.isDestructive, "deleting a group is styled as destructive") - let titles = content.rows.map { plain($0.title) } - let details = content.rows.map { plain($0.detail) } + XCTAssertFalse(content.isDestructive, "deleting a group leaves its credentials in Ungrouped") + let titles = content.rows.map(\.title) + let details = content.rows.map(\.detail) if language == "en" { XCTAssertEqual(titles, [ "Create the group “Staging Services”", @@ -32,13 +29,13 @@ final class OrganizationApprovalContentTests: AskKeyAppTestCase { "Its 1 credential won't be deleted and will become “Ungrouped”."]) } else { XCTAssertEqual(titles, [ - "新建分组「Staging Services」", // i18n-literal: Assert the Simplified Chinese organize step. - "把凭证「Staging API」从「Old Services」移到「Staging Services」", // i18n-literal: Assert the Simplified Chinese organize step. - "把分组「Old Services」改名为「Renamed Services」", // i18n-literal: Assert the Simplified Chinese organize step. - "删除分组「Temp」", // i18n-literal: Assert the Simplified Chinese organize step. + "新建分组“Staging Services”", // i18n-literal: Assert the Simplified Chinese organize step. + "把凭证“Staging API”从“Old Services”移到“Staging Services”", // i18n-literal: Assert the Simplified Chinese organize step. + "把分组“Old Services”改名为“Renamed Services”", // i18n-literal: Assert the Simplified Chinese organize step. + "删除分组“Temp”", // i18n-literal: Assert the Simplified Chinese organize step. ]) XCTAssertEqual(details, [nil, nil, "改名时组里有 3 个凭证(其中 2 个对 Agent 隐藏)。", // i18n-literal: Assert the Simplified Chinese step detail. - "组里 1 个凭证不会被删除,会变成「未分组」。"]) // i18n-literal: Assert the Simplified Chinese step detail. + "组里 1 个凭证不会被删除,会变成“未分组”。"]) // i18n-literal: Assert the Simplified Chinese step detail. } } } @@ -50,21 +47,21 @@ final class OrganizationApprovalContentTests: AskKeyAppTestCase { XCTAssertTrue(content.isDestructive, "a merge cannot be undone automatically") if language == "en" { XCTAssertEqual(content.rows.map(\.tag?.title), ["No change", "Merge"]) - XCTAssertEqual(plain(content.rows[0].title), "Create the group “Existing Private Services”") + XCTAssertEqual(content.rows[0].title, "Create the group “Existing Private Services”") XCTAssertEqual(content.rows[0].detail, "This group already exists, so nothing is created or changed. It has 1 credential (1 hidden from agents).") - XCTAssertEqual(plain(content.rows[1].title), + XCTAssertEqual(content.rows[1].title, "Claude Code asked to rename “Merge Source” to “Existing Merge Services”; “Existing Merge Services” already exists and is hidden from it, so the groups merge.") - XCTAssertEqual(plain(content.rows[1].detail), + XCTAssertEqual(content.rows[1].detail, "“Merge Source” disappears and “Existing Merge Services” will have 4 credentials (3 hidden from agents). A merge can't be undone automatically.") } else { XCTAssertEqual(content.rows.map(\.tag?.title), ["无变化", "合并"]) // i18n-literal: Assert Simplified Chinese organize tags. XCTAssertEqual(content.rows[0].detail, "这个分组已经存在,不会新建或改动任何内容。组里有 1 个凭证(其中 1 个对 Agent 隐藏)。") // i18n-literal: Assert the Simplified Chinese no-op detail. - XCTAssertEqual(plain(content.rows[1].title), - "Claude Code 请求的是把「Merge Source」改名为「Existing Merge Services」;「Existing Merge Services」已存在且对它隐藏,所以实际会合并。") // i18n-literal: Assert the Simplified Chinese merge step. - XCTAssertEqual(plain(content.rows[1].detail), - "合并后「Merge Source」消失,「Existing Merge Services」共有 4 个凭证(其中 3 个对 Agent 隐藏)。合并无法自动撤销。") // i18n-literal: Assert the Simplified Chinese merge result. + XCTAssertEqual(content.rows[1].title, + "Claude Code 请求的是把“Merge Source”改名为“Existing Merge Services”;“Existing Merge Services”已存在且对它隐藏,所以实际会合并。") // i18n-literal: Assert the Simplified Chinese merge step. + XCTAssertEqual(content.rows[1].detail, + "合并后“Merge Source”消失,“Existing Merge Services”共有 4 个凭证(其中 3 个对 Agent 隐藏)。合并无法自动撤销。") // i18n-literal: Assert the Simplified Chinese merge result. } } } @@ -76,7 +73,7 @@ final class OrganizationApprovalContentTests: AskKeyAppTestCase { .deleteGroup(name: "Unused", members: 0, nonvisible: 0), .existingGroup(name: "Shared", members: 0, nonvisible: 0), ]) - let details = FrozenOrganizationSummaryContent(summary: summary, requester: "Claude Code").rows.map { plain($0.detail) } + let details = FrozenOrganizationSummaryContent(summary: summary, requester: "Claude Code").rows.map(\.detail) XCTAssertEqual(details, language == "en" ? ["When it's renamed, the group has 2 credentials.", "The group is empty.", "This group already exists, so nothing is created or changed. The group is empty."] @@ -87,51 +84,45 @@ final class OrganizationApprovalContentTests: AskKeyAppTestCase { } } - func testTwelveStepsAreNumberedAndCountedInTitleAndButton() { + func testTwelveStepsAreNumberedAndCountedInTheSubtitle() { Fixtures.withLanguages { language in let content = FrozenOrganizationSummaryContent(summary: Fixtures.twelveSteps, requester: "Claude Code") XCTAssertEqual(content.rows.map(\.number), Array(1...12)) - let title = ApprovalPromptContent(request: Fixtures.request(.organize), credentialName: "credential-library", - organizationSteps: content.rows.count).title - XCTAssertFalse(title.contains("credential-library")) - let primary = FrozenApprovalActions.primary(operation: .organize, steps: 12, destructive: content.isDestructive) - XCTAssertEqual(primary.role, .destructive) + XCTAssertFalse(content.isDestructive) if language == "en" { - XCTAssertEqual(title, "Claude Code wants to reorganize your groups (12 steps)") - XCTAssertEqual(primary.title, "Apply 12 Steps") - XCTAssertEqual(plain(content.rows[1].title), "Move the credential “Service 2” from “Ungrouped” to “Group 1”") + XCTAssertEqual(content.subtitle, "12 steps, affecting 3 hidden credentials") + XCTAssertEqual(content.rows[1].title, "Move the credential “Service 2” from “Ungrouped” to “Group 1”") } else { - XCTAssertEqual(title, "Claude Code 想调整分组(共 12 步)") // i18n-literal: Assert the Simplified Chinese organize title. - XCTAssertEqual(primary.title, "执行这 12 步") // i18n-literal: Assert the Simplified Chinese organize button. - XCTAssertEqual(plain(content.rows[1].title), "把凭证「Service 2」从「未分组」移到「Group 1」") // i18n-literal: Assert the Simplified Chinese move step. + XCTAssertEqual(content.subtitle, "共 12 步,会动到 3 个隐藏的凭证") // i18n-literal: Assert the Simplified Chinese organize subtitle. + XCTAssertEqual(content.rows[1].title, "把凭证“Service 2”从“未分组”移到“Group 1”") // i18n-literal: Assert the Simplified Chinese move step. } } } - func testPendingListUsesTheSameObjectNamingVerbs() { + func testHiddenCredentialsAreCountedOnceAcrossSteps() { + func hidden(_ operations: [BrokerOrganizationSummary.Operation]) -> Int { + FrozenOrganizationSummaryContent.hiddenCredentials(BrokerOrganizationSummary(operations: operations)) + } + XCTAssertEqual(hidden([.renameGroup(from: "Old", to: "New", members: 3, nonvisible: 2), + .deleteGroup(name: "new", members: 3, nonvisible: 2)]), 2, "a renamed group deleted later") + XCTAssertEqual(hidden([.mergeGroup(from: "C", to: "B", members: 2, nonvisible: 1, targetMembers: 1, targetNonvisible: 1), + .deleteGroup(name: "B", members: 3, nonvisible: 2)]), 2, + "a merged-in credential and the target's own each count once") + XCTAssertEqual(hidden([.existingGroup(name: "Shared", members: 2, nonvisible: 2), + .move(credential: "API", from: nil, to: "Shared"), .createGroup("Empty")]), 0, + "steps that change no hidden credential's group") Fixtures.withLanguages { language in - let sentences = [ - Fixtures.request(.read, display: Fixtures.display()), Fixtures.request(.read), - Fixtures.request(.create), Fixtures.request(.modify), Fixtures.request(.delete), Fixtures.request(.organize), - ].map { request in - plain(PendingRequestPresentation(approval: BrokerPendingApproval(requestID: "request", capability: "capability", - request: request, trustedCredentialName: "Staging API")).sentence.plainText) - } - XCTAssertEqual(sentences, language == "en" ? [ - "Claude Code wants to use the credential “Staging API” to run ./deploy.sh --env staging", - "Claude Code wants to use the credential “Staging API”", - "Claude Code wants to create the credential “Staging API”", - "Claude Code wants to change the credential “Staging API”", - "Claude Code wants to delete the credential “Staging API”", - "Claude Code wants to reorganize your groups", - ] : [ - "Claude Code 想使用凭证「Staging API」运行 ./deploy.sh --env staging", // i18n-literal: Assert the Simplified Chinese pending sentence. - "Claude Code 想使用凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese pending sentence. - "Claude Code 想新建凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese pending sentence. - "Claude Code 想修改凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese pending sentence. - "Claude Code 想删除凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese pending sentence. - "Claude Code 想调整分组", // i18n-literal: Assert the Simplified Chinese pending sentence. - ]) + let one = FrozenOrganizationSummaryContent(summary: BrokerOrganizationSummary(operations: [ + .mergeGroup(from: "A", to: "B", members: 1, nonvisible: 1, targetMembers: 0, targetNonvisible: 0), + ]), requester: "Claude Code") + let two = FrozenOrganizationSummaryContent(summary: BrokerOrganizationSummary(operations: [ + .mergeGroup(from: "A", to: "B", members: 1, nonvisible: 0, targetMembers: 0, targetNonvisible: 0), + .mergeGroup(from: "C", to: "D", members: 1, nonvisible: 0, targetMembers: 0, targetNonvisible: 0), + ]), requester: "Claude Code") + XCTAssertEqual([one.subtitle, two.subtitle], language == "en" + ? ["1 step, one of which merges groups, affecting 1 hidden credential", "2 steps, 2 of which merge groups"] + : ["共 1 步,其中一步会合并分组,会动到 1 个隐藏的凭证", "共 2 步,其中 2 步会合并分组"]) // i18n-literal: Assert Simplified Chinese organize subtitles. + XCTAssertTrue(two.isDestructive) } } } diff --git a/Tests/AskKeyAppTests/ScreenPresentationTests.swift b/Tests/AskKeyAppTests/ScreenPresentationTests.swift index 50abc3a..8a1806f 100644 --- a/Tests/AskKeyAppTests/ScreenPresentationTests.swift +++ b/Tests/AskKeyAppTests/ScreenPresentationTests.swift @@ -33,18 +33,18 @@ final class ScreenPresentationTests: WorkspaceVisualContractTestSupport { trustedCredentialName: "demo-api" ) let presentation = PendingRequestPresentation(approval: approval) - XCTAssertEqual(unjoined(presentation.sentence.plainText), "Demo Agent 想使用凭证「demo-api」") // i18n-literal: Expected Simplified Chinese catalog value. + XCTAssertEqual(presentation.sentence.plainText, "“Demo Agent”想使用“demo-api”") // i18n-literal: Expected Simplified Chinese catalog value. XCTAssertFalse(presentation.sentence.plainText.contains("unverified purpose")) let now = Date(timeIntervalSince1970: 1_000) XCTAssertEqual( PendingRequestPresentation.expiry(deadline: now.addingTimeInterval(214), now: now), - "3:34 后自动失效" // i18n-literal: Expected Simplified Chinese catalog value. + "3:34 后失效" // i18n-literal: Expected Simplified Chinese catalog value. ) AppLanguage.current = "en" defer { AppLanguage.current = "zh-Hans" } XCTAssertEqual( - unjoined(PendingRequestPresentation(approval: approval).sentence.plainText), - "Demo Agent wants to use the credential “demo-api”" + PendingRequestPresentation(approval: approval).sentence.plainText, + "“Demo Agent” wants to use “demo-api”" ) } @@ -67,15 +67,10 @@ final class ScreenPresentationTests: WorkspaceVisualContractTestSupport { trustedCredentialName: "demo-api" ) let sentence = PendingRequestPresentation(approval: approval).sentence - XCTAssertEqual(unjoined(sentence.plainText), "Demo Agent 想使用凭证「demo-api」运行 ./deploy.sh --env staging") // i18n-literal: Expected Simplified Chinese catalog value. + XCTAssertEqual(sentence.plainText, "“Demo Agent”想使用“demo-api”运行 ./deploy.sh --env staging") // i18n-literal: Expected Simplified Chinese catalog value. XCTAssertEqual(sentence.runs.last, .init(text: "./deploy.sh --env staging", argument: 2)) } - /// Names are joined with invisible word joiners so they wrap as a whole. - private func unjoined(_ text: String) -> String { - text.replacingOccurrences(of: "\u{2060}", with: "").replacingOccurrences(of: "\u{00A0}", with: " ") - } - func testAccessRecordsGroupByDayNewestFirstWithColoredResults() { let now = Date(timeIntervalSince1970: 1_790_000_000) let today = calendar.startOfDay(for: now) diff --git a/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift b/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift index 7cd71dd..cb9aaac 100644 --- a/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift +++ b/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift @@ -103,16 +103,17 @@ final class WorkspaceInteractionTests: WorkspaceVisualContractTestSupport { AppLanguage.current = "zh-Hans" XCTAssertEqual( FrozenApprovalActions.titles(operation: .read, timedAllowanceEnabled: true), - ["允许本次", "30 分钟内都允许", "拒绝"] + ["允许本次", "允许 30 分钟", "拒绝"] ) XCTAssertEqual( FrozenApprovalActions.titles(operation: .read, timedAllowanceEnabled: false), ["允许本次", "拒绝"] ) let writeActions: [(BrokerApprovalOperation, String, String)] = [ - (.create, "新建凭证", "Create Credential"), - (.modify, "保存修改", "Save Changes"), - (.delete, "移到回收站", "Move to Recycle Bin"), + (.create, "新建", "Create"), + (.modify, "修改", "Change"), + (.delete, "删除", "Delete"), + (.organize, "执行", "Apply"), ] for (operation, chinese, _) in writeActions { XCTAssertEqual(FrozenApprovalActions.titles(operation: operation, timedAllowanceEnabled: true), diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index b0f29cf..2a14642 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -71,29 +71,29 @@ final class ScreenshotE2ETests: E2EBaseCase { app.launch() _ = try waitForEvidence("approval-pending.json") XCTAssertTrue(app.buttons["approval-details"].waitForExistence(timeout: 8)) - waitForCardText("Claude Code wants to use the credential %@", "Staging API") - waitForText("Command to run") - waitForText("./deploy.sh") - waitForCardText("If you allow, this command receives 1 item from %@", "Staging API") - waitForText("→ Environment variable STAGING_API_TOKEN") - waitForText("Stated purpose (not verified)") - waitForText("Deploy the staging site") + waitForText("“Claude Code” wants to use “Staging API”") + waitForText("to run ./deploy.sh", containing: true) XCTAssertEqual(app.buttons["approval-allow-once"].label, "Allow Once") XCTAssertEqual(app.buttons["approval-allow-timed"].label, "Allow for 30 Minutes") - waitForText("For 30 minutes, any agent or command in your Mac account can read this credential", containing: true) - waitForText("Esc to hide; decide in Pending requests before it expires", containing: true) + XCTAssertEqual(app.buttons["approval-deny"].label, "Deny") + waitForText("Esc to decide later") + XCTAssertFalse(app.staticTexts["Requested by"].exists, "everything else stays behind Details") capture("12-approval-default", approval: true) click("approval-details") - waitForText("Runs in") + waitForText("Requested by") waitForText("Claude Code (name provided by the requester; Ask Key can't verify it)") + waitForText("Environment variable STAGING_API_TOKEN") + waitForText("For 30 minutes, any agent or command in your Mac account can read this credential", containing: true) capture("13-approval-details", approval: true) click("approval-details") try sendFixtureCommand("cancel-authentication") click("approval-allow-once") let cancelled = try waitForEvidence("authentication-cancelled.json") XCTAssertEqual(cancelled["outcome"] as? String, "cancelled") - XCTAssertTrue(app.buttons["approval-retry-authentication"].waitForExistence(timeout: 8)) - XCTAssertEqual(app.buttons["approval-retry-authentication"].label, "Authenticate and Allow Once") + XCTAssertTrue(app.descendants(matching: .any)["approval-authentication-cancelled"].waitForExistence(timeout: 8)) + waitForText("Authentication cancelled. Nothing was handed over.") + XCTAssertEqual(app.buttons["approval-allow-once"].label, "Allow Once", "the default stays the same") + XCTAssertEqual(app.buttons["approval-allow-timed"].label, "Allow for 30 Minutes") try assertTargetExecutionCount(0) capture("14-approval-cancelled-authentication", approval: true) click("approval-deny") @@ -106,20 +106,22 @@ final class ScreenshotE2ETests: E2EBaseCase { app.launch() _ = try waitForEvidence("approval-pending.json") XCTAssertTrue(app.buttons["approval-deny"].waitForExistence(timeout: 8)) - waitForCardText("E2E Agent wants to create the credential %@", "Staging API") + waitForText("“E2E Agent” wants to create the credential “Staging API”") + waitForText("In the new group “Staging Services”") + XCTAssertEqual(app.buttons["approval-allow-once"].label, "Create") + XCTAssertFalse(app.buttons["approval-reveal-frozen-material"].exists, "values stay behind Details") + capture("15-approval-credential-metadata", approval: true) + click("approval-details") waitForText("Items (1, values provided by E2E Agent)") waitForText("token · 24 bytes · given to programs as environment variable STAGING_TOKEN") - XCTAssertTrue(app.buttons["approval-reveal-frozen-material"].exists) + XCTAssertTrue(app.buttons["approval-reveal-frozen-material"].waitForExistence(timeout: 8)) waitForText("Instructions for agents") waitForText("Use only for staging API requests.", containing: true) - waitForCardText("%@", "Staging Services") XCTAssertTrue(app.descendants(matching: .any)["approval-new-group"].waitForExistence(timeout: 8)) waitForText("Its agent permission will be Ask every time", containing: true) - XCTAssertEqual(app.buttons["approval-allow-once"].label, "Create Credential") XCTAssertFalse(app.staticTexts["Before"].exists) XCTAssertFalse(app.staticTexts["After"].exists) XCTAssertFalse(app.staticTexts["Unchanged"].exists) - capture("15-approval-credential-metadata", approval: true) click("approval-deny") let result = try waitForEvidence("approval-result.json") XCTAssertEqual(result["outcome"] as? String, "denied") @@ -131,26 +133,28 @@ final class ScreenshotE2ETests: E2EBaseCase { app.launch() _ = try waitForEvidence("approval-pending.json") XCTAssertTrue(app.buttons["approval-deny"].waitForExistence(timeout: 8)) - XCTAssertEqual(app.buttons["approval-allow-once"].label, "Apply 6 Steps") - waitForText("E2E Agent wants to reorganize your groups (6 steps)") - waitForCardText("Create the group %@", "Staging Services") - waitForCardText("Move the credential %@ from %@ to %@", "Staging API", "Old Services", "Staging Services") - waitForCardText("Rename the group %@ to %@", "Old Services", "Renamed Services") - waitForText("When it's renamed, the group has 3 credentials (2 hidden from agents).", containing: true) - waitForCardText("Its 3 credentials (2 hidden from agents) won't be deleted and will become %@.", "Ungrouped") + XCTAssertEqual(app.buttons["approval-allow-once"].label, "Apply") + waitForText("“E2E Agent” wants to organize your groups") + waitForText("6 steps, one of which merges groups, affecting 3 hidden credentials") XCTAssertFalse(app.buttons["approval-reveal-frozen-material"].exists) XCTAssertFalse(app.buttons["approval-allow-timed"].exists) capture("16-approval-organization", approval: true) + click("approval-details") + waitForText("Create the group “Staging Services”", containing: true) + waitForText("Move the credential “Staging API” from “Old Services” to “Staging Services”", containing: true) + waitForText("Rename the group “Old Services” to “Renamed Services”", containing: true) + waitForText("When it's renamed, the group has 3 credentials (2 hidden from agents).", containing: true) + waitForText("Its 3 credentials (2 hidden from agents) won't be deleted and will become “Ungrouped”.", containing: true) let operations = app.scrollViews["approval-organization-operations"] XCTAssertTrue(operations.exists) operations.scroll(byDeltaX: 0, deltaY: -600) - waitForCardText("Create the group %@", "Existing Private Services") + waitForText("Create the group “Existing Private Services”", containing: true) waitForText("This group already exists, so nothing is created or changed. It has 1 credential (1 hidden from agents).", containing: true) - waitForCardText("E2E Agent asked to rename %@ to %@; %@ already exists and is hidden from it, so the groups merge.", - "Merge Source", "Existing Merge Services", "Existing Merge Services") - waitForCardText("%@ disappears and %@ will have 4 credentials (3 hidden from agents). A merge can't be undone automatically.", - "Merge Source", "Existing Merge Services") + waitForText("E2E Agent asked to rename “Merge Source” to “Existing Merge Services”; “Existing Merge Services” already exists and is hidden from it, so the groups merge.", + containing: true) + waitForText("“Merge Source” disappears and “Existing Merge Services” will have 4 credentials (3 hidden from agents). A merge can't be undone automatically.", + containing: true) XCTAssertTrue(app.buttons["approval-allow-once"].isHittable) XCTAssertTrue(app.buttons["approval-deny"].isHittable) capture("17-approval-organization-existing-targets", approval: true) @@ -207,29 +211,6 @@ final class ScreenshotE2ETests: E2EBaseCase { .waitForExistence(timeout: 8), "Missing content: \(text)") } - /// Approval cards quote names and join their characters with invisible - /// word joiners so a name wraps as a whole; match that form, or the text - /// with the joiners removed. - private func waitForCardText(_ format: String, _ names: String...) { - func fill(_ quote: (String) -> String) -> String { - var text = format - for name in names { - guard let range = text.range(of: "%@") else { break } - text.replaceSubrange(range, with: quote(name)) - } - return text - } - let joined = fill { name in - "“\u{2060}" + name.map { ($0 == " " ? "\u{00A0}" : String($0)) + "\u{2060}" }.joined() + "”" - } - let plain = fill { "“" + $0 + "”" } - let predicate = NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", joined, joined) - if app.descendants(matching: .any).matching(predicate).firstMatch.waitForExistence(timeout: 8) { return } - let tree = app.debugDescription.replacingOccurrences(of: "\u{2060}", with: "") - .replacingOccurrences(of: "\u{00A0}", with: " ") - XCTAssertTrue(tree.contains(plain), "Missing card content: \(plain)") - } - private func replaceText(_ text: String, identifier: String) { let field = app.textFields[identifier].firstMatch XCTAssertTrue(field.waitForExistence(timeout: 8)) From ec3209c1b663f008ad6204d54ee0fa76986d8a7e Mon Sep 17 00:00:00 2001 From: sudoHG Date: Fri, 9 Oct 2026 21:56:06 +0800 Subject: [PATCH 2/3] Keep the approval panel on screen when Details expand Expanding Details can grow a card from about 300 to 640 points from its top, which pushed the buttons below a 768-point screen. The panel now moves up just enough to stay inside the screen's visible frame and stays where it is when it fits. --- .../App/AppDelegate+Approval.swift | 20 ++++++++++++++ .../AgentApprovalPanelTests.swift | 26 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift b/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift index 0d8ba9b..f351519 100644 --- a/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift +++ b/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift @@ -142,6 +142,7 @@ extension AppDelegate { ) { var finished = false var privacyTimer: Timer? + var resizeObserver: NSObjectProtocol? let contentSize = NSSize(width: FrozenAgentApprovalPrompt.width, height: 360) let panel = AgentApprovalPanelFactory.make(contentSize: contentSize) panel.titleVisibility = .hidden @@ -156,6 +157,8 @@ extension AppDelegate { finished = true privacyTimer?.invalidate() privacyTimer = nil + resizeObserver.map(NotificationCenter.default.removeObserver) + resizeObserver = nil panel.orderOut(nil) panel.contentViewController = nil let valid = Self.screenState() == .unlocked && expiresAt.map({ $0 > Date() }) != false @@ -236,6 +239,14 @@ extension AppDelegate { panel.contentViewController = hosting panel.setContentSize(hosting.view.fittingSize) panel.center() + // Expanding Details makes the card taller; keep its buttons on screen. + resizeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResizeNotification, object: panel, queue: .main + ) { [weak panel] _ in + MainActor.assumeIsolated { + if let panel { Self.keepInsideVisibleFrame(panel) } + } + } // Common modes keep the check running during menus, drags and modal pickers. privacyTimer = Timer(timeInterval: 0.25, repeats: true) { _ in MainActor.assumeIsolated { @@ -253,6 +264,15 @@ extension AppDelegate { panel.makeKeyAndOrderFront(nil) } + /// Moves the panel just enough to fit the screen's visible frame, keeping + /// its top visible when it is taller than the screen. + static func keepInsideVisibleFrame(_ panel: NSPanel) { + guard let visible = (panel.screen ?? NSScreen.main)?.visibleFrame else { return } + var origin = panel.frame.origin + origin.y = min(max(origin.y, visible.minY), visible.maxY - panel.frame.height) + if origin != panel.frame.origin { panel.setFrameOrigin(origin) } + } + nonisolated private static func screenState() -> AgentApprovalScreenState { AgentApprovalScreenSession.current() } diff --git a/Tests/AskKeyAppTests/AgentApprovalPanelTests.swift b/Tests/AskKeyAppTests/AgentApprovalPanelTests.swift index 4509e37..3eb4490 100644 --- a/Tests/AskKeyAppTests/AgentApprovalPanelTests.swift +++ b/Tests/AskKeyAppTests/AgentApprovalPanelTests.swift @@ -20,4 +20,30 @@ final class AgentApprovalPanelTests: AskKeyAppTestCase { XCTAssertFalse(panel.becomesKeyOnlyIfNeeded) XCTAssertTrue(panel.canBecomeKey) } + + /// Expanded Details make the card taller from its top; the panel moves up + /// just enough to keep the buttons on screen. Never shown on screen here. + @MainActor + func testTallerPanelMovesUpIntoTheVisibleFrame() throws { + _ = NSApplication.shared + let panel = AgentApprovalPanelFactory.make(contentSize: NSSize(width: 300, height: 300)) + guard let screen = NSScreen.main else { throw XCTSkip("No screen") } + let visible = screen.visibleFrame + let fitting = NSRect(x: visible.midX - 150, y: visible.midY - 150, width: 300, height: 300) + panel.setFrame(fitting, display: false) + let placed = panel.frame + AppDelegate.keepInsideVisibleFrame(panel) + XCTAssertEqual(panel.frame, placed, "a panel that fits stays where it is") + + let height = min(640, visible.height) + panel.setFrame(NSRect(x: placed.minX, y: visible.minY - 200, width: 300, height: height), display: false) + AppDelegate.keepInsideVisibleFrame(panel) + XCTAssertEqual(panel.frame.minY, visible.minY, accuracy: 0.5, "moved up just enough") + XCTAssertLessThanOrEqual(panel.frame.maxY, visible.maxY + 0.5) + XCTAssertEqual(panel.frame.minX, placed.minX) + + panel.setFrame(NSRect(x: placed.minX, y: visible.minY - 200, width: 300, height: visible.height + 100), display: false) + AppDelegate.keepInsideVisibleFrame(panel) + XCTAssertEqual(panel.frame.maxY, visible.maxY, accuracy: 0.5, "a panel taller than the screen keeps its top visible") + } } From f01ce7a09c7b29e6dd3674bb21c8d65648b49a6a Mon Sep 17 00:00:00 2001 From: sudoHG Date: Fri, 9 Oct 2026 22:34:16 +0800 Subject: [PATCH 3/3] Show approval Details as one two-column list Details mixed two layouts and three text sizes, repeated the subtitle in an "After you approve" paragraph, and cut lines off at the bottom. Every card now lists fixed rows with a fixed-width label column and 12-point values: the requester and purpose (both not verified), then per card the command, directory, what is handed over and the timed scope; the new items, instructions and value; the changed items with their tags, the instruction diff, the group change and the value; the deleted items; or each organize step. Nothing repeats the title or subtitle. When Details scroll, the hidden edge fades and the scroll bar stays visible. The write-card section views, item headings, byte counts and the approval consequences they showed are removed with their strings. --- .../App/ApprovalCardComponents.swift | 65 +---- Sources/AskKeyAppKit/App/ApprovalCopy.swift | 13 +- .../App/ApprovalDetailsContent.swift | 133 ++++++++++ .../App/ApprovalDetailsView.swift | 143 +++++++++++ .../App/ApprovalPromptContent.swift | 62 +---- .../AskKeyAppKit/App/ApprovalScrollArea.swift | 15 +- .../AskKeyAppKit/App/ApprovalTextDiff.swift | 24 +- .../App/FrozenAgentApprovalPrompt.swift | 57 +---- ...=> FrozenOrganizationSummaryContent.swift} | 35 +-- .../App/FrozenWriteApprovalContent.swift | 228 ----------------- .../App/FrozenWriteSummaryContent.swift | 137 +++------- .../Resources/Localizable.xcstrings | 240 ++++-------------- .../ApprovalDetailsContentTests.swift | 166 ++++++++++++ .../ApprovalPromptContentTests.swift | 61 +---- Tests/AskKeyAppTests/AskKeyAppSource.swift | 2 +- .../FrozenWriteSummaryContentTests.swift | 167 ++++-------- .../LocalizationUnificationTests.swift | 1 - Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 14 +- 18 files changed, 657 insertions(+), 906 deletions(-) create mode 100644 Sources/AskKeyAppKit/App/ApprovalDetailsContent.swift create mode 100644 Sources/AskKeyAppKit/App/ApprovalDetailsView.swift rename Sources/AskKeyAppKit/App/{FrozenOrganizationApprovalContent.swift => FrozenOrganizationSummaryContent.swift} (80%) delete mode 100644 Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift create mode 100644 Tests/AskKeyAppTests/ApprovalDetailsContentTests.swift diff --git a/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift index c6648d9..7f0db1e 100644 --- a/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift +++ b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift @@ -1,75 +1,14 @@ import AppKit import SwiftUI -/// A section of the card's Details: its heading, an optional status tag and -/// trailing control, then content. -struct ApprovalSection: View { - let title: String - let tag: ApprovalTag? - let identifier: String - let accessory: Accessory - let content: Content - - init(title: String, tag: ApprovalTag? = nil, identifier: String, - @ViewBuilder accessory: () -> Accessory, @ViewBuilder content: () -> Content) { - self.title = title - self.tag = tag - 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) - .accessibilityElement(children: .contain) - .accessibilityIdentifier(identifier) - } -} - -extension ApprovalSection where Accessory == EmptyView { - init(title: String, tag: ApprovalTag? = nil, identifier: String, @ViewBuilder content: () -> Content) { - self.init(title: title, tag: tag, 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. + /// replaced values and merges, red for removals. 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 } @@ -106,7 +45,7 @@ struct ApprovalLineText: View { var result = AttributedString() if let tag { var label = AttributedString("\u{2009}" + tag.title + "\u{2009}") - label.font = Theme.Fonts.caption.weight(.semibold) + label.font = Theme.Fonts.secondary.weight(.semibold) label.foregroundColor = tag.color label.backgroundColor = Theme.neutralSubtle result += label + AttributedString(" ") diff --git a/Sources/AskKeyAppKit/App/ApprovalCopy.swift b/Sources/AskKeyAppKit/App/ApprovalCopy.swift index 59ec0cc..da5d5e2 100644 --- a/Sources/AskKeyAppKit/App/ApprovalCopy.swift +++ b/Sources/AskKeyAppKit/App/ApprovalCopy.swift @@ -16,12 +16,6 @@ enum ApprovalCopy { 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 { @@ -95,17 +89,16 @@ struct ApprovalLine: Equatable { } } -/// Small status labels on neutral backgrounds in Details. Color carries risk, -/// not novelty. +/// Status labels in Details, at the text size on a neutral background. +/// Color carries risk, not novelty. enum ApprovalTag: Equatable { - case unchanged, changed, new, newGroup, replaced, removed, noChange, merge + case unchanged, changed, new, 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") diff --git a/Sources/AskKeyAppKit/App/ApprovalDetailsContent.swift b/Sources/AskKeyAppKit/App/ApprovalDetailsContent.swift new file mode 100644 index 0000000..ed807ab --- /dev/null +++ b/Sources/AskKeyAppKit/App/ApprovalDetailsContent.swift @@ -0,0 +1,133 @@ +import AppKit +import AskKeyBroker + +/// The rows under Details: a label and a value each, in a fixed order per +/// card. Nothing here repeats the card's title or subtitle. +struct ApprovalDetailsContent: Equatable { + struct Line: Equatable { + var tag: ApprovalTag? = nil + let text: ApprovalLine + /// A second line in the secondary color, such as a step's result. + var note: String? = nil + } + + enum Value: Equatable { + case text(String) + case code(String) + case lines([Line]) + /// Removed words struck through, added words highlighted, then the + /// removed phrases. + case diff(ApprovalTextDiff) + /// New values, masked until viewed with another authentication. + case revealableValue + } + + struct Row: Equatable { + let label: String + let value: Value + } + + let rows: [Row] + + init(request: BrokerApprovalOperationRequest, credentialName: String, write: FrozenWriteSummaryContent? = nil, + organization: FrozenOrganizationSummaryContent? = nil, timedAllowanceEnabled: Bool = false, + timedAllowanceMinutes: Int = 30) { + var rows = [Row(label: appLocalized("Requested by"), + value: .text(appLocalizedFormat("%@ (not verified)", ApprovalCopy.requester(request))))] + if let purpose = request.callerPurpose, !purpose.isEmpty { + rows.append(Row(label: appLocalized("Purpose"), value: .text(appLocalizedFormat("%@ (not verified)", purpose)))) + } + switch request.operation { + case .read: + if let display = request.display { + rows.append(Row(label: appLocalized("Command"), value: .code(display.commandLine))) + if let directory = display.workingDirectory, !directory.isEmpty { + rows.append(Row(label: appLocalized("Runs in"), value: .code(directory))) + } + rows.append(Row(label: appLocalized("Hands over"), + value: .lines(Self.handsOver(display, credentialName: credentialName)))) + } + if timedAllowanceEnabled { + rows.append(Row(label: appLocalizedFormat("%lld minutes", timedAllowanceMinutes), + value: .text(FrozenApprovalActions.timedScope(minutes: timedAllowanceMinutes)))) + } + case .create, .modify, .delete: + if let write { + rows += Self.writeRows(write) + } else if request.operation != .delete { + rows.append(Row(label: appLocalized("Value"), value: .revealableValue)) + } + case .organize: + rows += (organization?.rows ?? []).map { step in + Row(label: appLocalizedFormat("Step %lld", step.number), + value: .lines([Line(tag: step.tag, text: ApprovalLine(step.title), note: step.detail)])) + } + } + self.rows = rows + } + + /// One line per variable the command gets. A credential with a variable + /// has one item, named after the credential; a multi-item credential's + /// mapping is not opened before approval, so its items stay unnamed. + private static func handsOver(_ display: BrokerApprovalOperationRequest.Display, credentialName: String) -> [Line] { + guard let environment = display.environmentVariables, let files = display.temporaryFileVariables else { + return [Line(text: ApprovalLine(appLocalizedFormat( + "The items of %@ that are set to be given to programs (names are shown after you approve)", + ApprovalCopy.quoted(credentialName))))] + } + let lines = environment.map { + Line(text: ApprovalLine(format: appLocalized("%1$@ → environment variable %2$@"), plain: [credentialName], code: [$0])) + } + files.map { + Line(text: ApprovalLine(format: appLocalized("%1$@ → temporary file %2$@"), plain: [credentialName], code: [$0])) + } + return lines.isEmpty ? [Line(text: ApprovalLine(appLocalized("Nothing from this credential is given to the command")))] : lines + } + + private static func writeRows(_ write: FrozenWriteSummaryContent) -> [Row] { + var rows: [Row] = [] + switch write.operation { + case .create: + rows.append(Row(label: appLocalized("Contents"), value: .lines(write.components.map { component in + Line(text: ApprovalLine(format: "%@", code: [component.name]) + ApprovalLine(" · ") + + ApprovalCopy.delivery(component.delivery)) + }))) + if !write.instructions.isEmpty { + rows.append(Row(label: appLocalized("Instructions"), value: .text(write.instructions))) + } + case .modify: + let changed = write.changedComponents + if !changed.isEmpty { + rows.append(Row(label: appLocalized("Changes"), value: .lines(changed.map { + Line(tag: $0.tag, text: ApprovalLine(format: "%@", code: [$0.name])) + }))) + } + if let diff = write.instructionsDiff { + rows.append(Row(label: appLocalized("Instructions"), value: .diff(diff))) + } + if let group = write.groupChange { + let after = group.createsGroup ? appLocalizedFormat("%@ (new group)", group.after) : group.after + rows.append(Row(label: appLocalized("Group"), value: .text(group.before + " → " + after))) + } + case .delete: + rows.append(Row(label: appLocalized("Contents"), value: .lines(write.components.map { + Line(text: ApprovalLine(format: "%@", code: [$0.name])) + }))) + case .read, .organize: + break + } + if !write.valueComponents.isEmpty { + rows.append(Row(label: appLocalized("Value"), value: .revealableValue)) + } + return rows + } + + /// One label column for every card in the current language, wide enough + /// for the longest label. + static func labelWidth() -> CGFloat { + let labels = ["Requested by", "Purpose", "Command", "Runs in", "Hands over", "Contents", "Instructions", + "Changes", "Group", "Value"].map { appLocalized($0) } + + [appLocalizedFormat("%lld minutes", 999), appLocalizedFormat("Step %lld", 64)] + let font = NSFont.systemFont(ofSize: 12) + return ceil(labels.map { ($0 as NSString).size(withAttributes: [.font: font]).width }.max() ?? 0) + 1 + } +} diff --git a/Sources/AskKeyAppKit/App/ApprovalDetailsView.swift b/Sources/AskKeyAppKit/App/ApprovalDetailsView.swift new file mode 100644 index 0000000..92dffb5 --- /dev/null +++ b/Sources/AskKeyAppKit/App/ApprovalDetailsView.swift @@ -0,0 +1,143 @@ +import SwiftUI +import AskKeyBroker + +/// Details as one two-column list: a fixed-width label in the secondary color +/// and its value, all at the 12-point size. New values stay masked until +/// viewed with a separate authentication, which doesn't approve. +struct ApprovalDetailsView: View { + let content: ApprovalDetailsContent + var revealMaterial: (@MainActor () async throws -> FrozenApprovalMaterial)? + @State private var revealedMaterial: FrozenApprovalMaterial? + @State private var revealing = false + @State private var revealFailed = false + @State private var revealTask: Task? + + var body: some View { + let labelWidth = ApprovalDetailsContent.labelWidth() + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Rectangle().fill(Theme.separator).frame(height: 1) + ForEach(Array(content.rows.enumerated()), id: \.offset) { _, row in + HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.md) { + Text(verbatim: row.label) + .foregroundStyle(Theme.textSecondary) + .frame(width: labelWidth, alignment: .leading) + value(row.value) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .font(Theme.Fonts.secondary) + .foregroundStyle(Theme.text) + .onDisappear { revealTask?.cancel(); revealTask = nil; revealedMaterial = nil } + } + + @ViewBuilder + private func value(_ value: ApprovalDetailsContent.Value) -> some View { + switch value { + case .text(let text): + Text(verbatim: text).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + case .code(let code): + Text(verbatim: code).font(Theme.Fonts.mono).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + case .lines(let lines): + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + VStack(alignment: .leading, spacing: 2) { + ApprovalLineText(line: line.text, tag: line.tag) + if let note = line.note { + Text(verbatim: note) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .textSelection(.enabled) + case .diff(let diff): + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text(Self.attributed(diff.merged)).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + if !diff.removedPhrases.isEmpty { + Text(verbatim: appLocalizedFormat("Removed phrases: %@", + diff.removedPhrases.map(ApprovalCopy.quoted).joined(separator: appLocalized("List separator")))) + .foregroundStyle(Theme.warning) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("approval-instructions-removed") + } + } + case .revealableValue: + revealableValue + } + } + + /// Removed words in red with a line through them, added words in green. + private static func attributed(_ runs: [ApprovalTextDiff.Run]) -> AttributedString { + runs.reduce(into: AttributedString()) { result, run in + var part = AttributedString(run.text) + switch run.kind { + case .same: break + case .removed: + part.foregroundColor = Theme.warning + part.strikethroughStyle = .single + case .added: + part.foregroundColor = ApprovalTag.new.color + } + result += part + } + } + + @ViewBuilder + private var revealableValue: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + if let revealedMaterial { + HStack(spacing: Theme.Spacing.sm) { + Text(revealedMaterial.title).lineLimit(1) + Spacer(minLength: Theme.Spacing.xs) + Text(revealedMaterial.encoding).foregroundStyle(Theme.textSecondary).lineLimit(1) + Button(appLocalized("Hide")) { self.revealedMaterial = nil } + .buttonStyle(.plain) + .foregroundStyle(Theme.accent) + } + ApprovalScrollArea(space: "approval-revealed-value", maxHeight: 85, indicatorOffset: Theme.Spacing.sm - 2) { + Text(verbatim: revealedMaterial.content) + .font(Theme.Fonts.mono) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .padding(Theme.Spacing.sm) + .background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Radius.control)) + .accessibilityIdentifier("approval-revealed-value") + } else { + HStack(spacing: Theme.Spacing.sm) { + Text(verbatim: "••••••") + .foregroundStyle(Theme.textSecondary) + .accessibilityLabel(Text(appLocalized("Hidden value"))) + Button(appLocalized("Authenticate to View"), action: reveal) + .buttonStyle(.plain) + .foregroundStyle(Theme.accent) + .disabled(revealMaterial == nil || revealing) + .accessibilityIdentifier("approval-reveal-frozen-material") + } + } + if revealFailed { + Text(appLocalized("Unable to view: authentication was not completed or the request is no longer valid.")) + .foregroundStyle(Theme.warning) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func reveal() { + guard let revealMaterial, !revealing else { return } + revealing = true + revealFailed = false + revealTask = Task { + defer { revealing = false } + do { + let material = try await revealMaterial() + guard !Task.isCancelled else { return } + revealedMaterial = material + } catch { + if !Task.isCancelled { revealFailed = true } + } + } + } +} diff --git a/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift b/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift index 4a2308f..c7450ab 100644 --- a/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift +++ b/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift @@ -1,17 +1,9 @@ -import AppKit import AskKeyBroker -/// The card's title, subtitle and Details rows, derived from the pending -/// request and, for write and organize cards, their frozen summaries. The -/// requester's name and purpose are self-declared; the command, working -/// directory and delivered names come from the App-derived display summary. +/// The card's title and subtitle, derived from the pending request and, for +/// write and organize cards, their frozen summaries. The requester's name is +/// self-declared; the command comes from the App-derived display summary. struct ApprovalPromptContent: Equatable { - struct Row: Equatable { - let label: String - let value: String - var monospaced = false - } - /// One sentence naming who wants to do what with which object. let title: String /// Write and organize cards: one short line under the title. Read cards @@ -20,7 +12,6 @@ struct ApprovalPromptContent: Equatable { /// Read cards: the one-line command, already middle-truncated by the display summary. let commandSummary: String? let fullCommand: String? - let rows: [Row] init(request: BrokerApprovalOperationRequest, credentialName: String, write: FrozenWriteSummaryContent? = nil, organization: FrozenOrganizationSummaryContent? = nil) { @@ -42,19 +33,6 @@ struct ApprovalPromptContent: Equatable { let display = request.operation == .read ? request.display : nil commandSummary = display?.commandSummary fullCommand = display?.commandLine - var rows: [Row] = [] - if let display { - rows.append(Row(label: appLocalized("Command gets"), value: Self.receives(display))) - if let directory = display.workingDirectory, !directory.isEmpty { - rows.append(Row(label: appLocalized("Runs in"), value: directory, monospaced: true)) - } - } - rows.append(Row(label: appLocalized("Requested by"), - value: appLocalizedFormat("%@ (name provided by the requester; Ask Key can't verify it)", requester))) - if let purpose = request.callerPurpose, !purpose.isEmpty { - rows.append(Row(label: appLocalized("Stated purpose"), value: appLocalizedFormat("%@ (not verified)", purpose))) - } - self.rows = rows } /// Shared with the pending list so both say the same sentence. @@ -88,38 +66,4 @@ struct ApprovalPromptContent: Equatable { private static func sentence(_ format: String, _ arguments: String...) -> String { EmphasizedSentence(format: format, arguments: arguments).plainText } - - /// What the command gets and how, one line per variable. A multi-item - /// credential's mapping is not opened before approval, so its items are - /// described without names. - static func receives(_ display: BrokerApprovalOperationRequest.Display) -> String { - guard let environment = display.environmentVariables, let files = display.temporaryFileVariables else { - return appLocalized("The items set to be given to programs (names are shown after you approve)") - } - let lines = environment.map { appLocalizedFormat("Environment variable %@", $0) } - + files.map { appLocalizedFormat("Temporary file (path in %@, removed within 5 minutes)", $0) } - return lines.isEmpty - ? appLocalized("Nothing from this credential is given to the command") - : lines.joined(separator: "\n") - } - - /// Rows for Details. The full command leads when the one-line subtitle - /// cannot show all of it. - func detailRows(commandFits: Bool) -> [Row] { - guard let fullCommand, !fullCommand.isEmpty, !commandFits else { return rows } - return [Row(label: appLocalized("Command"), value: fullCommand, monospaced: true)] + rows - } - - /// Whether "to run " shows the whole command on one line of the - /// given width: not truncated by the display summary and not clipped. - func commandFits(prefix: String, width: CGFloat) -> Bool { - guard let commandSummary, commandSummary == fullCommand else { return false } - let prefixWidth = (prefix + " " as NSString).size( - withAttributes: [.font: NSFont.systemFont(ofSize: 13)] - ).width - let commandWidth = (commandSummary as NSString).size( - withAttributes: [.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)] - ).width - return prefixWidth + commandWidth <= width - } } diff --git a/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift index 82c8261..f0adc10 100644 --- a/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift +++ b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift @@ -1,7 +1,8 @@ import SwiftUI /// A region that scrolls when its content is taller than the space it gets. -/// While anything is hidden, a thin bar stays visible beside it. +/// While anything is hidden, a thin bar stays visible beside it and the edge +/// where lines continue fades out, so no line looks cut in half. struct ApprovalScrollArea: View { let space: String var maxHeight: CGFloat? @@ -23,6 +24,15 @@ struct ApprovalScrollArea: View { } .scrollIndicators(.never) .coordinateSpace(name: space) + .mask { + VStack(spacing: 0) { + LinearGradient(colors: [.clear, .black], startPoint: .top, endPoint: .bottom) + .frame(height: hiddenAbove ? Self.fade : 0) + Color.black + LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom) + .frame(height: hiddenBelow ? Self.fade : 0) + } + } .accessibilityIdentifier(identifier ?? space) .frame(maxHeight: maxHeight) .background(GeometryReader { geometry in @@ -35,7 +45,10 @@ struct ApprovalScrollArea: View { .onPreferenceChange(ApprovalScrollViewportKey.self) { viewport = $0[space] ?? 0 } } + private static var fade: CGFloat { 24 } private var overflows: Bool { contentFrame.height > viewport + 1 && viewport > 0 } + private var hiddenAbove: Bool { overflows && contentFrame.minY < -1 } + private var hiddenBelow: Bool { overflows && contentFrame.maxY > viewport + 1 } private var indicator: some View { let overflow = max(contentFrame.height - viewport, 1) diff --git a/Sources/AskKeyAppKit/App/ApprovalTextDiff.swift b/Sources/AskKeyAppKit/App/ApprovalTextDiff.swift index b9e1c3f..c6b289f 100644 --- a/Sources/AskKeyAppKit/App/ApprovalTextDiff.swift +++ b/Sources/AskKeyAppKit/App/ApprovalTextDiff.swift @@ -14,6 +14,8 @@ struct ApprovalTextDiff: Equatable { let before: [Run] /// The new text: unchanged and added runs. let after: [Run] + /// Both texts in one: unchanged, removed and added runs in reading order. + let merged: [Run] /// Each removed stretch of words without surrounding spaces and /// punctuation, for the "Removed:" line. let removedPhrases: [String] @@ -37,8 +39,24 @@ struct ApprovalTextDiff: Equatable { let newTail = Array(new[(new.count - suffix)...]).map { ($0, Kind.same) } let oldRuns = head + oldMiddle.indices.map { (oldMiddle[$0], oldKept.contains($0) ? Kind.same : .removed) } + oldTail let newRuns = head + newMiddle.indices.map { (newMiddle[$0], newKept.contains($0) ? Kind.same : .added) } + newTail - self.before = Self.merged(oldRuns) - self.after = Self.merged(newRuns) + // Kept tokens pair up in order, so walking both middles interleaves + // each removal before the addition that replaces it. + var middle: [(String, Kind)] = [] + var i = 0, j = 0 + while i < oldMiddle.count || j < newMiddle.count { + if i < oldMiddle.count, !oldKept.contains(i) { + middle.append((oldMiddle[i], .removed)); i += 1 + } else if j < newMiddle.count, !newKept.contains(j) { + middle.append((newMiddle[j], .added)); j += 1 + } else if i < oldMiddle.count, j < newMiddle.count { + middle.append((newMiddle[j], .same)); i += 1; j += 1 + } else { + break + } + } + self.before = Self.runs(oldRuns) + self.after = Self.runs(newRuns) + merged = Self.runs(head + middle + newTail) removedPhrases = self.before.filter { $0.kind == .removed } .map { $0.text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters)) } .filter { phrase in phrase.unicodeScalars.contains { CharacterSet.alphanumerics.contains($0) } } @@ -109,7 +127,7 @@ struct ApprovalTextDiff: Equatable { /// Joins neighbouring tokens of one kind; a lone space between two /// changed words joins the change so phrases read as one. - private static func merged(_ tokens: [(String, Kind)]) -> [Run] { + private static func runs(_ tokens: [(String, Kind)]) -> [Run] { var marked = tokens for index in marked.indices.dropFirst().dropLast() where marked[index].1 == .same && marked[index].0.allSatisfy(\.isWhitespace) diff --git a/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift b/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift index f02fedb..b496be5 100644 --- a/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift +++ b/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift @@ -37,6 +37,7 @@ struct FrozenAgentApprovalPrompt: View { let write: FrozenWriteSummaryContent? let organization: FrozenOrganizationSummaryContent? let content: ApprovalPromptContent + let details: ApprovalDetailsContent let buttons: [FrozenApprovalActions.Button] } @@ -44,13 +45,16 @@ struct FrozenAgentApprovalPrompt: View { let requester = ApprovalCopy.requester(request) let write = isRead || isOrganize ? nil : writeSummary.map { FrozenWriteSummaryContent(summary: $0, requester: requester) } let organization = isOrganize ? organizationSummary.map { FrozenOrganizationSummaryContent(summary: $0, requester: requester) } : nil - let content = ApprovalPromptContent(request: request, - credentialName: trustedCredentialName ?? request.credentialName ?? request.targetID, + let credentialName = trustedCredentialName ?? request.credentialName ?? request.targetID + let content = ApprovalPromptContent(request: request, credentialName: credentialName, write: write, organization: organization) + let details = ApprovalDetailsContent(request: request, credentialName: credentialName, write: write, + organization: organization, timedAllowanceEnabled: timedAllowanceEnabled, + timedAllowanceMinutes: timedAllowanceMinutes) let buttons = FrozenApprovalActions.buttons(operation: request.operation, timedAllowanceEnabled: timedAllowanceEnabled, minutes: timedAllowanceMinutes, valueOnlyChange: write?.valueOnlyChange ?? false, destructive: write?.isDestructive ?? organization?.isDestructive ?? false) - return Presentation(write: write, organization: organization, content: content, buttons: buttons) + return Presentation(write: write, organization: organization, content: content, details: details, buttons: buttons) } var body: some View { @@ -74,7 +78,7 @@ struct FrozenAgentApprovalPrompt: View { detailsToggle(expanded: expanded) .padding(.top, Theme.Spacing.md) if expanded { - details(content, write: presentation.write, organization: presentation.organization) + details(presentation.details) .padding(.top, Theme.Spacing.md) .layoutValue(key: ApprovalCardFlexibleKey.self, value: 1) } @@ -155,46 +159,11 @@ struct FrozenAgentApprovalPrompt: View { .accessibilityIdentifier("approval-details") } - private func details(_ content: ApprovalPromptContent, write: FrozenWriteSummaryContent?, - organization: FrozenOrganizationSummaryContent?) -> some View { - let rows = content.detailRows(commandFits: content.commandFits(prefix: appLocalized("to run"), - width: Self.contentWidth)) - return ApprovalScrollArea(space: "approval-details", - identifier: isOrganize ? "approval-organization-operations" : "approval-details-rows") { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - Rectangle().fill(Theme.separator).frame(height: 1) - Grid(alignment: .topLeading, horizontalSpacing: Theme.Spacing.md, verticalSpacing: Theme.Spacing.sm) { - ForEach(Array(rows.enumerated()), id: \.offset) { _, row in - GridRow { - Text(verbatim: row.label) - .foregroundStyle(Theme.textSecondary) - .fixedSize() - Text(verbatim: row.value) - .font(row.monospaced ? Theme.Fonts.mono : Theme.Fonts.body) - .foregroundStyle(Theme.text) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - .font(Theme.Fonts.body) - if isRead, timedAllowanceEnabled { - Text(verbatim: FrozenApprovalActions.timedScope(minutes: timedAllowanceMinutes)) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, Theme.Spacing.xs) - .accessibilityIdentifier("approval-timed-scope") - } - if let organization { - FrozenOrganizationApprovalContent(content: organization) - .padding(.top, Theme.Spacing.xs) - } else if !isRead && !isOrganize { - FrozenWriteApprovalContent(operation: request.operation, content: write, revealMaterial: revealMaterial) - .padding(.top, Theme.Spacing.xs) - } - } + /// Scrolls inside the card's cap; edges fade while lines are hidden. + private func details(_ details: ApprovalDetailsContent) -> some View { + ApprovalScrollArea(space: "approval-details", + identifier: isOrganize ? "approval-organization-operations" : "approval-details-rows") { + ApprovalDetailsView(content: details, revealMaterial: revealMaterial) } } diff --git a/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift b/Sources/AskKeyAppKit/App/FrozenOrganizationSummaryContent.swift similarity index 80% rename from Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift rename to Sources/AskKeyAppKit/App/FrozenOrganizationSummaryContent.swift index 79f0786..e73afd2 100644 --- a/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenOrganizationSummaryContent.swift @@ -1,4 +1,4 @@ -import SwiftUI +import Foundation import AskKeyBroker /// Each frozen step as a sentence. Counts come from the summary, which @@ -119,36 +119,3 @@ struct FrozenOrganizationSummaryContent: Equatable { : appLocalizedFormat("It has %@.", ApprovalCopy.members(members, hidden: hidden)) } } - -/// The ordered steps, shown in Details. -struct FrozenOrganizationApprovalContent: View { - let content: FrozenOrganizationSummaryContent - - var body: some View { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - ForEach(content.rows, id: \.number) { row in - HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { - Text(verbatim: "\(row.number).") - .monospacedDigit() - .foregroundStyle(Theme.textSecondary) - .frame(minWidth: 18, alignment: .trailing) - VStack(alignment: .leading, spacing: 2) { - ApprovalLineText(line: ApprovalLine(row.title), tag: row.tag) - .foregroundStyle(Theme.text) - if let detail = row.detail { - Text(verbatim: detail) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .textSelection(.enabled) - .accessibilityElement(children: .combine) - .accessibilityIdentifier("approval-organization-step-\(row.number)") - } - } - .font(Theme.Fonts.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } -} diff --git a/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift b/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift deleted file mode 100644 index c0dea14..0000000 --- a/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift +++ /dev/null @@ -1,228 +0,0 @@ -import SwiftUI -import AskKeyBroker - -/// A write card's Details: the items with their status tags, the -/// instructions and group, what approving does, and the separately -/// authenticated value view. -struct FrozenWriteApprovalContent: View { - let operation: BrokerApprovalOperation - var content: FrozenWriteSummaryContent? - var revealMaterial: (@MainActor () async throws -> FrozenApprovalMaterial)? - @State private var revealedMaterial: FrozenApprovalMaterial? - @State private var revealing = false - @State private var revealFailed = false - @State private var revealTask: Task? - - var body: some View { - VStack(alignment: .leading, spacing: Theme.Spacing.md) { - if let content { - if !content.components.isEmpty { items(content) } - instructions(content) - group(content) - if let consequence = content.consequence { - ApprovalSection(title: appLocalized("After you approve"), identifier: "approval-consequence") { - Text(verbatim: consequence).fixedSize(horizontal: false, vertical: true) - } - } - } else if operation == .create || operation == .modify { - // Without the summary the value can still be viewed before deciding. - ApprovalSection(title: appLocalized("Items"), identifier: "approval-components", - accessory: { revealButton }) { valueDetails } - } - } - .onDisappear { revealTask?.cancel(); revealTask = nil; revealedMaterial = nil } - } - - private func items(_ content: FrozenWriteSummaryContent) -> some View { - let hasValues = !content.valueComponents.isEmpty - return ApprovalSection(title: content.itemsHeading, identifier: "approval-components", - accessory: { if hasValues { revealButton } }) { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - ForEach(Array(content.components.enumerated()), id: \.offset) { _, component in - HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { - VStack(alignment: .leading, spacing: 2) { - ApprovalLineText(line: component.line, tag: component.tag) - ForEach(Array(component.notes.enumerated()), id: \.offset) { _, note in - ApprovalLineText(line: note) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) - } - if component.overwrites { - Text(appLocalized("If you approve, the old value is overwritten and can't be recovered.")) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.warning) - .fixedSize(horizontal: false, vertical: true) - } - } - if component.carriesValue { - Text(verbatim: "••••••").foregroundStyle(Theme.textSecondary).fixedSize() - .accessibilityLabel(Text(appLocalized("Hidden value"))) - } - } - } - if hasValues { valueDetails } - } - } - } - - @ViewBuilder - private var revealButton: some View { - if revealedMaterial != nil { - Button(appLocalized("Hide")) { revealedMaterial = nil } - .buttonStyle(.plain) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.accent) - .fixedSize() - } else { - Button(appLocalized("Authenticate to View"), action: reveal) - .buttonStyle(.plain) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.accent) - .fixedSize() - .disabled(revealMaterial == nil || revealing) - .accessibilityIdentifier("approval-reveal-frozen-material") - } - } - - @ViewBuilder - private var valueDetails: some View { - if let revealedMaterial { - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - HStack { - Text(revealedMaterial.title).lineLimit(1) - Spacer() - Text(revealedMaterial.encoding).foregroundStyle(Theme.textSecondary) - } - .font(Theme.Fonts.caption) - ApprovalScrollArea(space: "approval-revealed-value", maxHeight: 85, - indicatorOffset: Theme.Spacing.sm - 2) { - Text(verbatim: revealedMaterial.content) - .font(Theme.Fonts.mono) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(Theme.Spacing.sm) - .background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Radius.control)) - .accessibilityIdentifier("approval-revealed-value") - } else { - Text(appLocalized("Saved as is if you approve. Viewing needs another authentication; it doesn't approve.")) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - if revealFailed { - Text(appLocalized("Unable to view: authentication was not completed or the request is no longer valid.")) - .font(Theme.Fonts.caption).foregroundStyle(Theme.warning) - .fixedSize(horizontal: false, vertical: true) - } - } - - @ViewBuilder - private func instructions(_ content: FrozenWriteSummaryContent) -> some View { - switch content.instructions { - case .unchanged: - EmptyView() - case .current(let text): - ApprovalSection(title: appLocalized("Instructions for agents"), identifier: "approval-usage-instructions") { - Text(verbatim: text).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) - } - case .changed(let before, let after): - ApprovalSection(title: appLocalized("Instructions for agents"), tag: content.instructionsTag, - identifier: "approval-usage-instructions") { - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - comparison(before: diffText(content.instructionsDiff?.before, placeholder: before), - after: diffText(content.instructionsDiff?.after, placeholder: after)) - if let removed = content.instructionsDiff?.removedPhrases, !removed.isEmpty { - Text(appLocalizedFormat("Removed phrases: %@", - removed.map(ApprovalCopy.quoted).joined(separator: appLocalized("List separator")))) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.warning) - .fixedSize(horizontal: false, vertical: true) - .accessibilityIdentifier("approval-instructions-removed") - } - } - } - } - } - - /// Removed words in red with a line through them, added words in green. - private func diffText(_ runs: [ApprovalTextDiff.Run]?, placeholder: String) -> Text { - guard let runs, !runs.isEmpty else { return Text(verbatim: placeholder) } - return Text(runs.reduce(into: AttributedString()) { result, run in - var part = AttributedString(run.text) - switch run.kind { - case .same: break - case .removed: - part.foregroundColor = Theme.warning - part.strikethroughStyle = .single - case .added: - part.foregroundColor = ApprovalTag.new.color - } - result += part - }) - } - - @ViewBuilder - private func group(_ content: FrozenWriteSummaryContent) -> some View { - switch content.group { - case .unchanged: - EmptyView() - case .current(let name): - ApprovalSection(title: appLocalized("Group"), identifier: "approval-group-changes") { - groupName(name, createsGroup: content.createsGroup) - } - case .changed(let before, let after): - ApprovalSection(title: appLocalized("Group"), tag: content.groupTag, identifier: "approval-group-changes") { - comparison(before: Text(verbatim: before), after: groupName(after, createsGroup: content.createsGroup)) - } - } - } - - private func groupName(_ name: String, createsGroup: Bool) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(verbatim: name).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) - if createsGroup { - HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { - ApprovalTagView(tag: .newGroup) - Text(appLocalized("Created when you approve")) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) - } - .accessibilityElement(children: .combine) - .accessibilityIdentifier("approval-new-group") - } - } - } - - private func comparison(before: some View, after: some View) -> some View { - Grid(alignment: .topLeading, horizontalSpacing: Theme.Spacing.sm, verticalSpacing: Theme.Spacing.xs) { - GridRow { - Text(appLocalized("Before")).foregroundStyle(Theme.textSecondary).fixedSize() - before.textSelection(.enabled).fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - GridRow { - Text(appLocalized("After")).foregroundStyle(Theme.textSecondary).fixedSize() - after.textSelection(.enabled).fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - - private func reveal() { - guard let revealMaterial, !revealing else { return } - revealing = true - revealFailed = false - revealTask = Task { - defer { revealing = false } - do { - let material = try await revealMaterial() - guard !Task.isCancelled else { return } - revealedMaterial = material - } catch { - if !Task.isCancelled { revealFailed = true } - } - } - } -} diff --git a/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift b/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift index 0db2ad9..df5cfe2 100644 --- a/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift @@ -5,23 +5,17 @@ import AskKeyBroker struct FrozenWriteSummaryContent: Equatable { struct Component: Equatable { let name: String - /// Set on modify only; create and delete show plain rows. + /// Set on modify only: how this item changes. let tag: ApprovalTag? - let line: ApprovalLine - let notes: [ApprovalLine] - /// Its value is new: the row shows masked dots and can be viewed. + let delivery: BrokerComponentDelivery + /// Its value is new, so it can be viewed before approving. let carriesValue: Bool - /// A replaced value: the row warns that the old one is overwritten. - let overwrites: Bool - let byteCount: Int } - enum Change: Equatable { - /// Create and delete show the one value involved. - case current(String) - case changed(before: String, after: String) - /// Modify: left out of Details. - case unchanged + struct GroupChange: Equatable { + let before: String + let after: String + let createsGroup: Bool } let operation: BrokerApprovalOperation @@ -29,68 +23,42 @@ struct FrozenWriteSummaryContent: Equatable { /// a change does. Delete says the same for every card, so it has none here. let subtitle: String? let components: [Component] - let itemCount: Int - let itemsHeading: String - let instructions: Change - let instructionsTag: ApprovalTag? + /// Create: the instructions to save; empty when there are none. + let instructions: String + /// Modify: the word-level change, when the instructions change. let instructionsDiff: ApprovalTextDiff? - let group: Change - let groupTag: ApprovalTag? - let createsGroup: Bool - /// What approving does, stated in Details. - let consequence: String? + /// Modify: the group before and after, when it changes. + let groupChange: GroupChange? let valueOnlyChange: Bool /// A modify that overwrites or removes values that can't be recovered. let isDestructive: Bool var valueComponents: [Component] { components.filter(\.carriesValue) } + /// Modify: the items that change, in order; unchanged ones are left out. + var changedComponents: [Component] { components.filter { $0.tag != nil && $0.tag != .unchanged } } init(summary: BrokerCredentialWriteSummary, requester: String) { operation = summary.operation let modify = summary.operation == .modify let components = modify - ? Self.modifiedComponents(summary, requester: requester) + ? Self.modifiedComponents(summary) : (summary.operation == .delete ? summary.before : summary.after).map { - Component(name: $0.name, tag: nil, line: Self.line($0), notes: [], - carriesValue: summary.operation == .create, overwrites: false, byteCount: $0.byteCount) + Component(name: $0.name, tag: nil, delivery: $0.delivery, carriesValue: summary.operation == .create) } - let componentsChanged = !modify || components.contains { $0.tag != .unchanged } self.components = components - itemCount = (summary.operation == .delete ? summary.before : summary.after).count - switch summary.operation { - case .create: - itemsHeading = appLocalizedFormat("Items (%1$lld, values provided by %2$@)", itemCount, requester) - case .modify where components.contains(where: \.carriesValue): - itemsHeading = appLocalizedFormat("Items (%1$lld, new values provided by %2$@)", itemCount, requester) - case .read, .modify, .delete, .organize: - itemsHeading = appLocalizedFormat("Items (%lld)", itemCount) - } - let beforeInstructions = summary.beforeUsageInstructions ?? "" let afterInstructions = summary.afterUsageInstructions ?? "" let instructionsChanged = beforeInstructions != afterInstructions let groupChanged = summary.beforeGroup != summary.afterGroup - switch summary.operation { - case .modify: - instructions = instructionsChanged - ? .changed(before: Self.instructionsText(beforeInstructions), after: Self.instructionsText(afterInstructions)) - : .unchanged - group = groupChanged - ? .changed(before: Self.groupText(summary.beforeGroup), after: Self.groupText(summary.afterGroup)) - : .unchanged - case .delete, .read, .organize: - instructions = .current(Self.instructionsText(beforeInstructions)) - group = .current(Self.groupText(summary.beforeGroup)) - case .create: - instructions = .current(Self.instructionsText(afterInstructions)) - group = .current(Self.groupText(summary.afterGroup)) - } - instructionsTag = modify && instructionsChanged ? .changed : nil + instructions = summary.operation == .create ? afterInstructions : "" instructionsDiff = modify && instructionsChanged ? ApprovalTextDiff(before: beforeInstructions, after: afterInstructions) : nil - groupTag = modify && groupChanged ? .changed : nil - createsGroup = summary.createsGroup && (summary.operation == .create || summary.operation == .modify) - valueOnlyChange = modify && componentsChanged && !instructionsChanged && !groupChanged + groupChange = modify && groupChanged + ? GroupChange(before: ApprovalCopy.group(summary.beforeGroup), after: ApprovalCopy.group(summary.afterGroup), + createsGroup: summary.createsGroup) + : nil + let componentsChanged = modify && components.contains { $0.tag != .unchanged } + valueOnlyChange = componentsChanged && !instructionsChanged && !groupChanged && components.allSatisfy { $0.tag == .unchanged || $0.tag == .replaced } isDestructive = modify && components.contains { $0.tag == .replaced || $0.tag == .removed } switch summary.operation { @@ -110,26 +78,16 @@ struct FrozenWriteSummaryContent: Equatable { case .read, .delete, .organize: subtitle = nil } - switch summary.operation { - case .create: - consequence = appLocalized("Its agent permission will be Ask every time, so agents need your approval for each use.") - case .delete: - consequence = appLocalized("It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile.") - case .read, .modify, .organize: - consequence = nil - } } /// Each item is matched by name and tagged from its own value digest. - private static func modifiedComponents(_ summary: BrokerCredentialWriteSummary, - requester: String) -> [Component] { + private static func modifiedComponents(_ summary: BrokerCredentialWriteSummary) -> [Component] { let digestsEqual = summary.beforeDigest != nil && summary.beforeDigest == summary.afterDigest let before = Dictionary(summary.before.map { (ApprovalCopy.matchKey($0.name), $0) }, uniquingKeysWith: { first, _ in first }) let afterNames = Set(summary.after.map { ApprovalCopy.matchKey($0.name) }) var rows = summary.after.map { after -> Component in guard let old = before[ApprovalCopy.matchKey(after.name)] else { - return Component(name: after.name, tag: .new, line: line(after), notes: [], - carriesValue: true, overwrites: false, byteCount: after.byteCount) + return Component(name: after.name, tag: .new, delivery: after.delivery, carriesValue: true) } let valueChanged: Bool if let oldDigest = old.valueDigest, let newDigest = after.valueDigest { @@ -139,45 +97,18 @@ struct FrozenWriteSummaryContent: Equatable { valueChanged = old.byteCount != after.byteCount || old.payloadKind != after.payloadKind || !digestsEqual } if valueChanged { - var notes = [ApprovalLine(appLocalizedFormat("Old value %1$@ → new value %2$@ (new value from %3$@)", - ApprovalCopy.bytes(old.byteCount), ApprovalCopy.bytes(after.byteCount), requester))] - if old.delivery != after.delivery { - notes.append(previous(ApprovalCopy.delivery(old.delivery))) - } - return Component(name: after.name, tag: .replaced, - line: ApprovalLine(after.name + " · ") + ApprovalCopy.delivery(after.delivery), - notes: notes, carriesValue: true, overwrites: true, byteCount: after.byteCount) - } - if old.delivery != after.delivery || old.masked != after.masked || old.name != after.name { - var notes = [previous(line(old))] - if old.masked != after.masked { - notes.append(ApprovalLine(after.masked - ? appLocalized("Now masked when shown in Ask Key") - : appLocalized("Now shown in full in Ask Key"))) - } - return Component(name: after.name, tag: .changed, line: line(after), notes: notes, - carriesValue: false, overwrites: false, byteCount: after.byteCount) + return Component(name: after.name, tag: .replaced, delivery: after.delivery, carriesValue: true) } - return Component(name: after.name, tag: .unchanged, line: line(after), notes: [], - carriesValue: false, overwrites: false, byteCount: after.byteCount) + let settingsChanged = old.delivery != after.delivery || old.masked != after.masked || old.name != after.name + return Component(name: after.name, tag: settingsChanged ? .changed : .unchanged, delivery: after.delivery, + carriesValue: false) } rows += summary.before.filter { !afterNames.contains(ApprovalCopy.matchKey($0.name)) }.map { - Component(name: $0.name, tag: .removed, line: line($0), notes: [], carriesValue: false, - overwrites: false, byteCount: $0.byteCount) + Component(name: $0.name, tag: .removed, delivery: $0.delivery, carriesValue: false) } return rows } - /// "{name} · {N} bytes · {delivery in plain words}". - static func line(_ item: BrokerCredentialComponentSummary) -> ApprovalLine { - ApprovalLine(item.name + " · " + ApprovalCopy.bytes(item.byteCount) + " · ") + ApprovalCopy.delivery(item.delivery) - } - - private static func previous(_ line: ApprovalLine) -> ApprovalLine { - let parts = appLocalized("Before: %@").components(separatedBy: "%@") - return ApprovalLine(parts.first ?? "") + line + ApprovalLine(parts.dropFirst().joined()) - } - /// What a change does, in the order replace, change, add, remove, then /// which values are lost: "Replaces 1 value, changes the group; the old /// value can't be recovered". @@ -220,12 +151,4 @@ struct FrozenWriteSummaryContent: Equatable { } return ApprovalCopy.capitalized(lost) } - - private static func instructionsText(_ value: String) -> String { - value.isEmpty ? appLocalized("None") : value - } - - private static func groupText(_ name: String?) -> String { - name.map(ApprovalCopy.quoted) ?? appLocalized("Ungrouped") - } } diff --git a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings index b4c5211..d0eeb93 100644 --- a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings +++ b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings @@ -17,6 +17,22 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ asked to rename %2$@ to %3$@; %3$@ already exists and is hidden from it, so the groups merge.!!]" } } } }, + "%1$@ → environment variable %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%1$@ → environment variable %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ → 环境变量 %2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ → environment variable %2$@!!]" } } + } + }, + "%1$@ → temporary file %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%1$@ → temporary file %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ → 临时文件 %2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ → temporary file %2$@!!]" } } + } + }, "%1$@, %2$lld of which merge groups": { "extractionState": "manual", "localizations": { @@ -33,6 +49,14 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@, affecting %2$lld hidden credentials!!]" } } } }, + "%@ (new group)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%@ (new group)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@(新分组)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ (new group)!!]" } } + } + }, "%@ (some instruction text is deleted)": { "extractionState": "manual", "localizations": { @@ -121,14 +145,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 step!!]" } } } }, - "After you approve": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "After you approve" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!After you approve!!]" } } - } - }, "Apply": { "extractionState": "manual", "localizations": { @@ -153,6 +169,14 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Change!!]" } } } }, + "Changes": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Changes" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "改动" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Changes!!]" } } + } + }, "Clause separator": { "extractionState": "manual", "localizations": { @@ -169,14 +193,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Command!!]" } } } }, - "Command gets": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Command gets" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "命令会拿到" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Command gets!!]" } } - } - }, "Create credential button": { "extractionState": "manual", "localizations": { @@ -185,22 +201,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Create!!]" } } } }, - "Created when you approve": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Created when you approve" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后创建" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Created when you approve!!]" } } - } - }, - "Environment variable %@": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Environment variable %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "环境变量 %@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Environment variable %@!!]" } } - } - }, "Esc to decide later": { "extractionState": "manual", "localizations": { @@ -217,20 +217,20 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.!!]" } } } }, - "Hidden value": { + "Hands over": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Hidden value" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "已隐藏的值" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Hidden value!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Hands over" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "交出" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Hands over!!]" } } } }, - "If you approve, the old value is overwritten and can't be recovered.": { + "Hidden value": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "If you approve, the old value is overwritten and can't be recovered." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后旧值会被覆盖,无法找回。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!If you approve, the old value is overwritten and can't be recovered.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Hidden value" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已隐藏的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Hidden value!!]" } } } }, "In the group %@": { @@ -249,28 +249,12 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!In the new group %@!!]" } } } }, - "Items": { + "Instructions": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Items" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items!!]" } } - } - }, - "Items (%1$lld, new values provided by %2$@)": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Items (%1$lld, new values provided by %2$@)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容(%1$lld 项,新值由 %2$@ 提供)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items (%1$lld, new values provided by %2$@)!!]" } } - } - }, - "Items (%1$lld, values provided by %2$@)": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Items (%1$lld, values provided by %2$@)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容(%1$lld 项,值由 %2$@ 提供)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items (%1$lld, values provided by %2$@)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Instructions" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "说明" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Instructions!!]" } } } }, "Moves to the Recycle Bin; restorable for 30 days": { @@ -281,12 +265,12 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Moves to the Recycle Bin; restorable for 30 days!!]" } } } }, - "Old value %1$@ → new value %2$@ (new value from %3$@)": { + "Purpose": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Old value %1$@ → new value %2$@ (new value from %3$@)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "旧值 %1$@ → 新值 %2$@(新值由 %3$@ 提供)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Old value %1$@ → new value %2$@ (new value from %3$@)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Purpose" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "用途" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Purpose!!]" } } } }, "Removed phrases: %@": { @@ -305,28 +289,20 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Replace!!]" } } } }, - "Stated purpose": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Stated purpose" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "对方说的用途" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Stated purpose!!]" } } - } - }, - "Temporary file (path in %@, removed within 5 minutes)": { + "Step %lld": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Temporary file (path in %@, removed within 5 minutes)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "临时文件(路径在 %@,最多 5 分钟后删除)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Temporary file (path in %@, removed within 5 minutes)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Step %lld" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 步" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Step %lld!!]" } } } }, - "The items set to be given to programs (names are shown after you approve)": { + "The items of %@ that are set to be given to programs (names are shown after you approve)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "The items set to be given to programs (names are shown after you approve)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "设为交给程序的所有项(具体名称批准后才能看到)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The items set to be given to programs (names are shown after you approve)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "The items of %@ that are set to be given to programs (names are shown after you approve)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@里设为交给程序的所有项(具体名称批准后才能看到)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The items of %@ that are set to be given to programs (names are shown after you approve)!!]" } } } }, "The old value can't be recovered": { @@ -377,30 +353,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ disappears and %2$@ will have %3$@.!!]" } } } }, - "%@ (name provided by the requester; Ask Key can't verify it)": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ (name provided by the requester; Ask Key can't verify it)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@(名称由请求方提供,请旨无法核实)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ (name provided by the requester; Ask Key can't verify it)!!]" } } - } - }, - "%@ bytes": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ bytes" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 字节" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ bytes!!]" } } - } - }, - "1 byte": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "1 byte" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "1 字节" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 byte!!]" } } - } - }, "A merge can't be undone automatically.": { "extractionState": "manual", "localizations": { @@ -425,14 +377,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Authenticate to View!!]" } } } }, - "Before: %@": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Before: %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "原来:%@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Before: %@!!]" } } - } - }, "Changed": { "extractionState": "manual", "localizations": { @@ -457,14 +401,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Delete the group %@!!]" } } } }, - "Instructions for agents": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Instructions for agents" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "给 Agent 的使用说明" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Instructions for agents!!]" } } - } - }, "It has %@.": { "extractionState": "manual", "localizations": { @@ -473,22 +409,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!It has %@.!!]" } } } }, - "It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile.": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证会移到回收站保留 30 天,期间可以在回收站恢复,之后永久删除。这段时间里 Agent 不能使用,也看不到它。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile.!!]" } } - } - }, - "Items (%lld)": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Items (%lld)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容(%lld 项)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items (%lld)!!]" } } - } - }, "Its %1$@ won't be deleted and will become %2$@.": { "extractionState": "manual", "localizations": { @@ -521,14 +441,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!New!!]" } } } }, - "New group tag": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "New group" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "新分组" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!New group!!]" } } - } - }, "No change": { "extractionState": "manual", "localizations": { @@ -553,22 +465,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Nothing from this credential is given to the command!!]" } } } }, - "Now masked when shown in Ask Key": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Now masked when shown in Ask Key" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "现在在请旨里默认遮挡显示" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Now masked when shown in Ask Key!!]" } } - } - }, - "Now shown in full in Ask Key": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Now shown in full in Ask Key" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "现在在请旨里默认完整显示" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Now shown in full in Ask Key!!]" } } - } - }, "Removed": { "extractionState": "manual", "localizations": { @@ -609,14 +505,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": " " } } } }, - "Its agent permission will be Ask every time, so agents need your approval for each use.": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Its agent permission will be Ask every time, so agents need your approval for each use." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "这个凭证的 Agent 权限是「每次询问」:Agent 每次使用都要你批准。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Its agent permission will be Ask every time, so agents need your approval for each use.!!]" } } - } - }, "The group is empty.": { "extractionState": "manual", "localizations": { @@ -633,14 +521,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This group already exists, so nothing is created or changed.!!]" } } } }, - "Saved as is if you approve. Viewing needs another authentication; it doesn't approve.": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Saved as is if you approve. Viewing needs another authentication; it doesn't approve." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后保存的就是这个值。查看要再验证一次,查看不等于批准。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Saved as is if you approve. Viewing needs another authentication; it doesn't approve.!!]" } } - } - }, "adds %lld items": { "extractionState": "manual", "localizations": { @@ -729,14 +609,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Unchanged!!]" } } } }, - "None": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "None" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "无" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!None!!]" } } - } - }, "Requested by": { "extractionState": "manual", "localizations": { diff --git a/Tests/AskKeyAppTests/ApprovalDetailsContentTests.swift b/Tests/AskKeyAppTests/ApprovalDetailsContentTests.swift new file mode 100644 index 0000000..eec185f --- /dev/null +++ b/Tests/AskKeyAppTests/ApprovalDetailsContentTests.swift @@ -0,0 +1,166 @@ +import XCTest +import AskKeyBroker +@testable import AskKeyAppKit + +/// Details are one list of label and value rows, in a fixed order per card, +/// that never repeats the title or subtitle. +@MainActor +final class ApprovalDetailsContentTests: AskKeyAppTestCase { + private typealias Fixtures = ApprovalCardFixtures + private typealias Card = ApprovalCardFixtures.Card + + private func rows(_ card: Card) -> [ApprovalDetailsContent.Row] { + Fixtures.prompt(card).presentation.details.rows + } + + private func labels(_ card: Card) -> [String] { + rows(card).map(\.label) + } + + /// The row as plain text: tags lead their line, notes follow on the next. + private func text(_ value: ApprovalDetailsContent.Value) -> String { + switch value { + case .text(let text), .code(let text): return text + case .lines(let lines): + return lines.map { line in + (line.tag.map { $0.title + " " } ?? "") + line.text.plainText + (line.note.map { "\n" + $0 } ?? "") + }.joined(separator: "\n") + case .diff(let diff): return "diff: " + diff.removedPhrases.joined(separator: ", ") + case .revealableValue: return "••••••" + } + } + + private func values(_ card: Card) -> [String] { + rows(card).map { text($0.value) } + } + + func testReadRowsInBothLanguages() { + Fixtures.withLanguages { language in + let scope = FrozenApprovalActions.timedScope(minutes: 30) + if language == "en" { + XCTAssertEqual(labels(.readDefault), ["Requested by", "Purpose", "Command", "Runs in", "Hands over", "30 minutes"]) + XCTAssertEqual(values(.readDefault), ["Claude Code (not verified)", "Deploy the staging site (not verified)", + "./deploy.sh --env staging", "~/web", "Staging API → environment variable STAGING_API_TOKEN", scope]) + XCTAssertEqual(values(.readFile)[4], "Staging API → temporary file STAGING_CERT_FILE") + XCTAssertEqual(values(.readBundle)[4], + "The items of “Staging API” that are set to be given to programs (names are shown after you approve)") + } else { + XCTAssertEqual(labels(.readDefault), ["请求方", "用途", "命令", "运行目录", "交出", "30 分钟"]) // i18n-literal: Chinese read labels. + XCTAssertEqual(values(.readDefault), ["Claude Code(未核实)", "Deploy the staging site(未核实)", // i18n-literal: Chinese read values. + "./deploy.sh --env staging", "~/web", "Staging API → 环境变量 STAGING_API_TOKEN", scope]) // i18n-literal: Chinese read values. + XCTAssertEqual(values(.readFile)[4], "Staging API → 临时文件 STAGING_CERT_FILE") // i18n-literal: Chinese file row. + XCTAssertEqual(values(.readBundle)[4], "“Staging API”里设为交给程序的所有项(具体名称批准后才能看到)") // i18n-literal: Chinese bundle row. + } + XCTAssertEqual(rows(.readDefault)[2].value, .code("./deploy.sh --env staging"), "the command is monospaced") + XCTAssertEqual(rows(.readDefault)[3].value, .code("~/web")) + XCTAssertEqual(labels(.readWithoutTimed).count, 5, "no timed row without the timed option") + let command = "./deploy.sh " + (1...30).map { "--flag-\($0) value" }.joined(separator: " ") + let long = ApprovalDetailsContent(request: Fixtures.request(.read, display: Fixtures.display(command: command)), + credentialName: "Staging API") + XCTAssertEqual(long.rows[2].value, .code(command), "Details show the whole command") + } + } + + func testCreateRowsInBothLanguages() { + Fixtures.withLanguages { language in + if language == "en" { + XCTAssertEqual(labels(.create), ["Requested by", "Purpose", "Contents", "Instructions", "Value"]) + XCTAssertEqual(values(.create)[2], [ + "TOKEN · given to programs as environment variable RELEASE_CHECK_TOKEN", + "CERT · given to programs as a temporary file (path in RELEASE_CERT_FILE)", + "RECOVERY_CODE · kept in Ask Key only, never given to agents", + ].joined(separator: "\n")) + } else { + XCTAssertEqual(labels(.create), ["请求方", "用途", "内容", "说明", "值"]) // i18n-literal: Chinese create labels. + XCTAssertEqual(values(.create)[2], [ + "TOKEN · 使用时作为环境变量 RELEASE_CHECK_TOKEN 交给程序", // i18n-literal: Chinese create item. + "CERT · 使用时作为临时文件交给程序(路径在 RELEASE_CERT_FILE)", // i18n-literal: Chinese create item. + "RECOVERY_CODE · 只存在请旨里,不交给任何 Agent", // i18n-literal: Chinese create item. + ].joined(separator: "\n")) + } + XCTAssertEqual(values(.create)[3], "Use only for the release smoke check. Keep values out of logs.") + XCTAssertEqual(rows(.create)[4].value, .revealableValue) + guard case .lines(let items) = rows(.create)[2].value else { return XCTFail("Expected item lines") } + XCTAssertEqual(items.first?.text.segments.filter(\.code).map(\.text), ["TOKEN", "RELEASE_CHECK_TOKEN"]) + XCTAssertEqual(labels(.createUngrouped).count, 4, "no instructions row when there are none") + } + } + + func testChangeRowsListOnlyWhatChanges() { + Fixtures.withLanguages { language in + let requester = language == "en" ? ["Requested by", "Purpose"] : ["请求方", "用途"] // i18n-literal: Chinese labels. + let changes = language == "en" ? "Changes" : "改动" // i18n-literal: Chinese label. + let instructions = language == "en" ? "Instructions" : "说明" // i18n-literal: Chinese label. + let group = language == "en" ? "Group" : "分组" // i18n-literal: Chinese label. + let value = language == "en" ? "Value" : "值" // i18n-literal: Chinese label. + XCTAssertEqual(labels(.modifyMetadata), requester + [instructions]) + XCTAssertEqual(values(.modifyMetadata)[2], "diff: checks", "the diff names what was deleted") + XCTAssertEqual(labels(.modifyInstructionsAndGroup), requester + [instructions, group]) + XCTAssertEqual(values(.modifyInstructionsAndGroup)[3], "“Release Tools” → “Operations”") + XCTAssertEqual(labels(.modifyValue), requester + [changes, value]) + XCTAssertEqual(labels(.modifySomeValues), requester + [changes, value]) + XCTAssertEqual(labels(.modifyAdded), requester + [changes, value]) + XCTAssertEqual(labels(.modifyRemoved), requester + [changes]) + XCTAssertEqual(labels(.modifyMixed), requester + [changes, group, value]) + XCTAssertEqual([values(.modifySomeValues)[2], values(.modifyAdded)[2], values(.modifyRemoved)[2]], language == "en" + ? ["Replaced TOKEN", "New CERT", "Removed USER"] : ["替换 TOKEN", "新增 CERT", "移除 USER"]) // i18n-literal: Chinese change rows. + let newGroup = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, + before: [Fixtures.token], after: [Fixtures.token], beforeDigest: "same", afterDigest: "same", + beforeGroup: "Release Tools", afterGroup: "Operations", createsGroup: true) + XCTAssertEqual(values(Fixtures.write(.modify, newGroup).presentation.details.rows).last, language == "en" + ? "“Release Tools” → “Operations” (new group)" : "“Release Tools” → “Operations”(新分组)") // i18n-literal: Chinese new group. + } + } + + private func values(_ rows: [ApprovalDetailsContent.Row]) -> [String] { + rows.map { text($0.value) } + } + + func testDeleteAndOrganizeRowsInBothLanguages() { + Fixtures.withLanguages { language in + XCTAssertEqual(labels(.delete), language == "en" + ? ["Requested by", "Purpose", "Contents"] : ["请求方", "用途", "内容"]) // i18n-literal: Chinese delete labels. + XCTAssertEqual(values(.delete)[2], "TOKEN\nUSER") + XCTAssertEqual(labels(.organize), language == "en" + ? ["Requested by", "Purpose", "Step 1", "Step 2", "Step 3", "Step 4"] + : ["请求方", "用途", "第 1 步", "第 2 步", "第 3 步", "第 4 步"]) // i18n-literal: Chinese organize labels. + XCTAssertEqual(values(.organize)[2], language == "en" + ? "Create the group “Staging Services”" : "新建分组“Staging Services”") // i18n-literal: Chinese organize step. + XCTAssertEqual(values(.organizeExistingAndMerge)[2], language == "en" + ? "No change Create the group “Existing Private Services”\nThis group already exists, so nothing is created or changed. It has 1 credential (1 hidden from agents)." + : "无变化 新建分组“Existing Private Services”\n这个分组已经存在,不会新建或改动任何内容。组里有 1 个凭证(其中 1 个对 Agent 隐藏)。") // i18n-literal: Chinese organize step. + XCTAssertEqual(labels(.organizeTwelveSteps).count, 14) + } + } + + func testDetailsNeverRepeatTheTitleOrSubtitle() { + Fixtures.withLanguages { language in + for card in Card.allCases { + let copy = Fixtures.copy(card) + for value in values(card) { + XCTAssertNotEqual(value, copy.title, "\(language) \(card.rawValue)") + if let subtitle = copy.subtitle { + XCTAssertFalse(value.contains(subtitle), "\(language) \(card.rawValue): \(value)") + } + } + XCTAssertFalse(labels(card).contains(language == "en" ? "After you approve" : "批准后"), // i18n-literal: Chinese label. + "\(language) \(card.rawValue)") + } + } + } + + func testWriteWithoutASummaryStillOffersTheValue() { + let request = Fixtures.request(.create) + let details = ApprovalDetailsContent(request: request, credentialName: "Staging API") + XCTAssertEqual(details.rows.last?.value, .revealableValue) + XCTAssertEqual(ApprovalDetailsContent(request: Fixtures.request(.delete), credentialName: "Staging API").rows.count, 2) + } + + func testOneLabelColumnForEveryCard() { + Fixtures.withLanguages { language in + let width = ApprovalDetailsContent.labelWidth() + XCTAssertGreaterThan(width, 30) + XCTAssertLessThan(width, 100, "\(language): values keep most of the card's width") + } + } +} diff --git a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift index b79986c..a7144a0 100644 --- a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift +++ b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift @@ -131,59 +131,14 @@ final class ApprovalPromptContentTests: AskKeyAppTestCase { } } - func testReadDetailsHoldTheRequesterPurposeDeliveryAndTimedScope() { - Fixtures.withLanguages { language in - let read = ApprovalPromptContent(request: Fixtures.request(.read, display: Fixtures.display()), credentialName: "Staging API") - XCTAssertTrue(read.commandFits(prefix: appLocalized("to run"), width: FrozenAgentApprovalPrompt.contentWidth)) - let rows = read.detailRows(commandFits: true) - if language == "en" { - XCTAssertEqual(rows.map(\.label), ["Command gets", "Runs in", "Requested by", "Stated purpose"]) - XCTAssertEqual(rows.map(\.value), ["Environment variable STAGING_API_TOKEN", "~/web", - "Claude Code (name provided by the requester; Ask Key can't verify it)", - "Deploy the staging site (not verified)"]) - XCTAssertEqual(FrozenApprovalActions.timedScope(minutes: 30), - "For 30 minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.") - } else { - XCTAssertEqual(rows.map(\.label), ["命令会拿到", "运行目录", "请求方", "对方说的用途"]) // i18n-literal: Chinese detail labels. - XCTAssertEqual(rows.map(\.value), ["环境变量 STAGING_API_TOKEN", "~/web", // i18n-literal: Chinese detail values. - "Claude Code(名称由请求方提供,请旨无法核实)", "Deploy the staging site(未核实)"]) // i18n-literal: Chinese detail values. - XCTAssertEqual(FrozenApprovalActions.timedScope(minutes: 30), - "30 分钟内,你这个 Mac 账户下的任何 Agent 或命令读取这个凭证都不再询问;修改或删除它仍要你批准。") // i18n-literal: Chinese timed scope. - } - XCTAssertEqual(rows.map(\.monospaced), [false, true, false, false]) - } - } - - func testReadDetailsDescribeFilesBundlesAndLongCommands() { - Fixtures.withLanguages { language in - func receives(_ display: BrokerApprovalOperationRequest.Display) -> String { - ApprovalPromptContent.receives(display) - } - let values = [ - receives(Fixtures.display(environment: ["DEPLOY_HOST"], files: ["SSH_KEY_FILE"])), - receives(Fixtures.display(environment: nil, files: nil)), - receives(Fixtures.display(environment: [], files: [])), - ] - XCTAssertEqual(values, language == "en" ? [ - "Environment variable DEPLOY_HOST\nTemporary file (path in SSH_KEY_FILE, removed within 5 minutes)", - "The items set to be given to programs (names are shown after you approve)", - "Nothing from this credential is given to the command", - ] : [ - "环境变量 DEPLOY_HOST\n临时文件(路径在 SSH_KEY_FILE,最多 5 分钟后删除)", // i18n-literal: Chinese delivery rows. - "设为交给程序的所有项(具体名称批准后才能看到)", // i18n-literal: Chinese bundle row. - "不交给这个命令任何值", // i18n-literal: Chinese no-delivery row. - ]) - let command = "./deploy.sh " + (1...30).map { "--flag-\($0) value" }.joined(separator: " ") - let long = ApprovalPromptContent(request: Fixtures.request(.read, display: Fixtures.display(command: command)), - credentialName: "Staging API") - XCTAssertFalse(long.commandFits(prefix: appLocalized("to run"), width: FrozenAgentApprovalPrompt.contentWidth)) - let rows = long.detailRows(commandFits: false) - XCTAssertEqual(rows.first?.label, language == "en" ? "Command" : "命令") // i18n-literal: Chinese command label. - XCTAssertEqual(rows.first?.value, command, "Details show the whole command") - XCTAssertTrue(rows.first?.monospaced == true) - XCTAssertNil(ApprovalPromptContent(request: Fixtures.request(.create, display: Fixtures.display()), - credentialName: "Staging API").commandSummary, "write cards never show a run target") - } + func testReadSubtitleKeepsTheV02OneLineCommand() { + let command = "./deploy.sh " + (1...30).map { "--flag-\($0) value" }.joined(separator: " ") + let long = ApprovalPromptContent(request: Fixtures.request(.read, display: Fixtures.display(command: command)), + credentialName: "Staging API") + XCTAssertEqual(long.fullCommand, command) + XCTAssertNotEqual(long.commandSummary, command, "the subtitle shortens a long command in the middle") + XCTAssertNil(ApprovalPromptContent(request: Fixtures.request(.create, display: Fixtures.display()), + credentialName: "Staging API").commandSummary, "write cards never show a run target") } func testNamesAreQuotedInBothLanguagesAndNeverShortened() { diff --git a/Tests/AskKeyAppTests/AskKeyAppSource.swift b/Tests/AskKeyAppTests/AskKeyAppSource.swift index c64ceb2..c8c9343 100644 --- a/Tests/AskKeyAppTests/AskKeyAppSource.swift +++ b/Tests/AskKeyAppTests/AskKeyAppSource.swift @@ -21,7 +21,7 @@ enum AskKeyAppSource { "Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift", "Sources/AskKeyAppKit/App/FrozenApprovalActions.swift", "Sources/AskKeyAppKit/App/FrozenApprovalMaterial.swift", - "Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift", + "Sources/AskKeyAppKit/App/ApprovalDetailsView.swift", "Sources/AskKeyAppKit/App/FrozenWriteRevealCopy.swift", "Sources/AskKeyAppKit/App/HostingWindowSizing.swift", "Sources/AskKeyAppKit/App/LockedApprovalReminderDeliveryPolicy.swift", diff --git a/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift b/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift index 0438240..3e3a4d8 100644 --- a/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift +++ b/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift @@ -4,9 +4,8 @@ import AskKeyBroker @testable import AskKeyAppKit @testable import AskKeyVault -/// Write cards: the subtitle that says what a change does, and in Details -/// plain-language item rows with exact status tags and what happens after -/// approval. +/// Write cards: exact per-item tags, what counts as a value-only or +/// irreversible change, the instruction diff, and the subtitle. @MainActor final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { private typealias Fixtures = ApprovalCardFixtures @@ -15,10 +14,6 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { FrozenWriteSummaryContent(summary: summary, requester: "Claude Code") } - private func lines(_ content: FrozenWriteSummaryContent) -> [String] { - content.components.map(\.line.plainText) - } - private func modification(before: [BrokerCredentialComponentSummary], after: [BrokerCredentialComponentSummary], beforeDigest: String? = "old", afterDigest: String? = "new") -> BrokerCredentialWriteSummary { .init(credentialName: "Release Check", operation: .modify, before: before, after: after, @@ -26,93 +21,66 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { afterUsageInstructions: "Use for release checks.", beforeGroup: "Release Tools", afterGroup: "Release Tools") } - func testCreateListsEveryItemWithItsValueAndStatesThePermission() { - Fixtures.withLanguages { language in - let create = content(Fixtures.createSummary) - if language == "en" { - XCTAssertEqual(create.itemsHeading, "Items (3, values provided by Claude Code)") - XCTAssertEqual(lines(create), [ - "TOKEN · 28 bytes · given to programs as environment variable RELEASE_CHECK_TOKEN", - "CERT · 1,234 bytes · given to programs as a temporary file (path in RELEASE_CERT_FILE)", - "RECOVERY_CODE · 16 bytes · kept in Ask Key only, never given to agents", - ]) - XCTAssertEqual(create.consequence, - "Its agent permission will be Ask every time, so agents need your approval for each use.") - } else { - XCTAssertEqual(create.itemsHeading, "凭证内容(3 项,值由 Claude Code 提供)") // i18n-literal: Assert the Simplified Chinese items heading. - XCTAssertEqual(lines(create), [ - "TOKEN · 28 字节 · 使用时作为环境变量 RELEASE_CHECK_TOKEN 交给程序", // i18n-literal: Assert the Simplified Chinese item row. - "CERT · 1,234 字节 · 使用时作为临时文件交给程序(路径在 RELEASE_CERT_FILE)", // i18n-literal: Assert the Simplified Chinese item row. - "RECOVERY_CODE · 16 字节 · 只存在请旨里,不交给任何 Agent", // i18n-literal: Assert the Simplified Chinese item row. - ]) - XCTAssertEqual(create.consequence, "这个凭证的 Agent 权限是「每次询问」:Agent 每次使用都要你批准。") // i18n-literal: Assert the Simplified Chinese permission statement. - } - XCTAssertEqual(create.components.map(\.tag), [nil, nil, nil], "create rows carry no status tags") - XCTAssertEqual(create.components.first?.line.segments.filter(\.code).map(\.text), ["RELEASE_CHECK_TOKEN"]) - XCTAssertEqual(create.valueComponents.map(\.name), ["TOKEN", "CERT", "RECOVERY_CODE"]) - XCTAssertEqual(create.instructions, .current("Use only for the release smoke check. Keep values out of logs.")) - XCTAssertEqual(create.group, .current("“Release Tools”")) - XCTAssertTrue(create.createsGroup) - XCTAssertFalse(create.isDestructive) - } + func testCreateCarriesEveryItemAndItsInstructions() { + let create = content(Fixtures.createSummary) + XCTAssertEqual(create.components.map(\.name), ["TOKEN", "CERT", "RECOVERY_CODE"]) + XCTAssertEqual(create.components.map(\.tag), [nil, nil, nil], "create rows carry no status tags") + XCTAssertEqual(create.valueComponents.map(\.name), ["TOKEN", "CERT", "RECOVERY_CODE"]) + XCTAssertEqual(create.instructions, "Use only for the release smoke check. Keep values out of logs.") + XCTAssertNil(create.groupChange, "the subtitle names the group") + XCTAssertFalse(create.isDestructive) } - func testMetadataOnlyChangeTagsEveryItemUnchanged() { - Fixtures.withLanguages { language in - let modify = content(Fixtures.metadataSummary) - XCTAssertEqual(modify.components.map(\.tag), [.unchanged, .unchanged], "Details list every item with its tag") - XCTAssertTrue(modify.valueComponents.isEmpty) - XCTAssertEqual(modify.instructionsTag, .changed) - XCTAssertEqual(modify.group, .unchanged) - XCTAssertFalse(modify.valueOnlyChange) - XCTAssertFalse(modify.isDestructive) - XCTAssertNil(modify.consequence) - XCTAssertEqual(modify.subtitle, language == "en" - ? "Changes the instructions (some instruction text is deleted)" - : "更改使用说明(删掉了部分说明)") // i18n-literal: Assert the Simplified Chinese change subtitle. - } + func testMetadataOnlyChangeLeavesEveryItemUnchanged() { + let modify = content(Fixtures.metadataSummary) + XCTAssertEqual(modify.components.map(\.tag), [.unchanged, .unchanged]) + XCTAssertTrue(modify.changedComponents.isEmpty) + XCTAssertTrue(modify.valueComponents.isEmpty) + XCTAssertNotNil(modify.instructionsDiff) + XCTAssertNil(modify.groupChange) + XCTAssertFalse(modify.valueOnlyChange) + XCTAssertFalse(modify.isDestructive) } - func testValueOnlyChangeTagsTheReplacementAndWarnsAboutTheOldValue() { - Fixtures.withLanguages { language in - let modify = content(Fixtures.valueSummary) - XCTAssertTrue(modify.valueOnlyChange) - XCTAssertTrue(modify.isDestructive) - XCTAssertEqual(modify.components.map(\.tag), [.replaced]) - XCTAssertEqual(modify.components.map(\.overwrites), [true]) - XCTAssertEqual(modify.valueComponents.map(\.byteCount), [52]) - XCTAssertEqual(modify.instructions, .unchanged) - let replacement = modify.components.first?.notes.map(\.plainText) - if language == "en" { - XCTAssertEqual(lines(modify), ["TOKEN · given to programs as environment variable RELEASE_CHECK_TOKEN"]) - XCTAssertEqual(replacement, ["Old value 40 bytes → new value 52 bytes (new value from Claude Code)"]) - XCTAssertEqual(modify.subtitle, "The old value can't be recovered") - XCTAssertEqual(modify.itemsHeading, "Items (1, new values provided by Claude Code)") - XCTAssertEqual(appLocalized("If you approve, the old value is overwritten and can't be recovered."), - "If you approve, the old value is overwritten and can't be recovered.") - } else { - XCTAssertEqual(replacement, ["旧值 40 字节 → 新值 52 字节(新值由 Claude Code 提供)"]) // i18n-literal: Assert the Simplified Chinese replacement row. - XCTAssertEqual(modify.subtitle, "旧值将无法找回") // i18n-literal: Assert the Simplified Chinese value subtitle. - XCTAssertEqual(modify.itemsHeading, "凭证内容(1 项,新值由 Claude Code 提供)") // i18n-literal: Assert the Simplified Chinese items heading. - XCTAssertEqual(appLocalized("If you approve, the old value is overwritten and can't be recovered."), - "批准后旧值会被覆盖,无法找回。") // i18n-literal: Assert the Simplified Chinese overwrite line. - } - } + func testValueOnlyChangeIsIrreversible() { + let modify = content(Fixtures.valueSummary) + XCTAssertTrue(modify.valueOnlyChange) + XCTAssertTrue(modify.isDestructive) + XCTAssertEqual(modify.changedComponents.map(\.tag), [.replaced]) + XCTAssertEqual(modify.valueComponents.map(\.name), ["TOKEN"]) + XCTAssertNil(modify.instructionsDiff) + } + + func testAddedItemLosesNothing() { + let modify = content(Fixtures.addedSummary) + XCTAssertEqual(modify.components.map(\.tag), [.unchanged, .unchanged, .new]) + XCTAssertEqual(modify.changedComponents.map(\.name), ["CERT"]) + XCTAssertEqual(modify.valueComponents.map(\.name), ["CERT"], "only the new value can be viewed") + XCTAssertFalse(modify.valueOnlyChange) + XCTAssertFalse(modify.isDestructive) } - func testAddedItemLeavesTheOthersUnchanged() { + func testGroupChangeNamesBothGroupsAndANewGroup() { Fixtures.withLanguages { language in - let modify = content(Fixtures.addedSummary) - XCTAssertEqual(modify.components.map(\.tag), [.unchanged, .unchanged, .new]) - XCTAssertEqual(modify.valueComponents.map(\.name), ["CERT"], "only the new value is listed") - XCTAssertFalse(modify.valueOnlyChange) - XCTAssertFalse(modify.isDestructive, "adding an item loses nothing") - XCTAssertEqual(modify.subtitle, language == "en" ? "Adds 1 item" : "新增 1 项") // i18n-literal: Assert the Simplified Chinese change subtitle. - XCTAssertEqual([ApprovalTag.unchanged, .new, .replaced].map(\.title), language == "en" - ? ["Unchanged", "New", "Replaced"] : ["不变", "新增", "替换"]) // i18n-literal: Assert Simplified Chinese tags. + let moved = content(Fixtures.instructionsAndGroupSummary).groupChange + XCTAssertEqual(moved, .init(before: "“Release Tools”", after: "“Operations”", createsGroup: false)) + let ungrouped = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, + before: [Fixtures.token], after: [Fixtures.token], beforeDigest: "same", afterDigest: "same", + beforeGroup: nil, afterGroup: "Operations", createsGroup: true) + XCTAssertEqual(content(ungrouped).groupChange, .init(before: language == "en" ? "“Ungrouped”" : "“未分组”", // i18n-literal: Chinese ungrouped name. + after: "“Operations”", createsGroup: true)) } } + func testDeleteListsTheItemsAndLosesNothing() { + let delete = content(Fixtures.deleteSummary) + XCTAssertEqual(delete.components.map(\.name), ["TOKEN", "USER"]) + XCTAssertEqual(delete.components.map(\.tag), [nil, nil]) + XCTAssertTrue(delete.valueComponents.isEmpty) + XCTAssertNil(delete.subtitle, "the card states the Recycle Bin for every delete") + XCTAssertFalse(delete.isDestructive, "a deleted credential can be restored") + } + func testEachItemIsTaggedFromItsOwnValueDigest() { let previous = AppLanguage.current defer { AppLanguage.current = previous } @@ -127,8 +95,7 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { let moved = Fixtures.component("TOKEN", bytes: 28, .temporaryFile("RELEASE_CHECK_FILE")) let changed = content(modification(before: [token, user], after: [moved])) XCTAssertEqual(changed.components.map(\.tag), [.changed, .removed]) - XCTAssertEqual(changed.components.first?.notes.map(\.plainText), - ["Before: TOKEN · 28 bytes · given to programs as environment variable RELEASE_CHECK_TOKEN"]) + XCTAssertEqual(changed.changedComponents.map(\.name), ["TOKEN", "USER"]) XCTAssertTrue(changed.valueComponents.isEmpty, "a new delivery for the same value adds no value to view") XCTAssertEqual(changed.subtitle, "Changes the settings of 1 item, removes 1 item; the removed value can't be recovered") XCTAssertTrue(changed.isDestructive) @@ -169,6 +136,9 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { XCTAssertEqual(diff.after.map(\.text).joined(), "Use only for the nightly release checks.") XCTAssertEqual(diff.after.filter { $0.kind == .added }.map(\.text), ["only ", "the nightly "]) XCTAssertEqual(diff.removedPhrases, ["Keep values out of logs"]) + XCTAssertEqual(diff.merged.map(\.kind), [.same, .added, .same, .added, .same, .removed, .same]) + XCTAssertEqual(diff.merged.filter { $0.kind != .added }.map(\.text).joined(), diff.before.map(\.text).joined()) + XCTAssertEqual(diff.merged.filter { $0.kind != .removed }.map(\.text).joined(), diff.after.map(\.text).joined()) let chinese = ApprovalTextDiff(before: "只用于发布检查,不要写进日志。", after: "只用于夜间发布检查。") // i18n-literal: Synthetic Chinese instructions. XCTAssertEqual(chinese.after.filter { $0.kind == .added }.map(\.text), ["夜间"]) // i18n-literal: Synthetic Chinese diff. XCTAssertEqual(chinese.removedPhrases, ["不要写进日志"]) // i18n-literal: Synthetic Chinese diff. @@ -181,31 +151,6 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { } } - func testDeleteShowsWhatIsDeletedAndWhereItGoes() { - Fixtures.withLanguages { language in - let delete = content(Fixtures.deleteSummary) - XCTAssertEqual(delete.components.map(\.tag), [nil, nil]) - XCTAssertTrue(delete.valueComponents.isEmpty) - XCTAssertEqual(delete.itemsHeading, language == "en" ? "Items (2)" : "凭证内容(2 项)") // i18n-literal: Assert the Simplified Chinese items heading. - XCTAssertEqual(delete.instructions, .current("Use for release checks.")) - XCTAssertNil(delete.subtitle, "the card states the Recycle Bin for every delete") - XCTAssertFalse(delete.isDestructive, "a deleted credential can be restored") - XCTAssertEqual(delete.consequence, language == "en" - ? "It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile." - : "凭证会移到回收站保留 30 天,期间可以在回收站恢复,之后永久删除。这段时间里 Agent 不能使用,也看不到它。") // i18n-literal: Assert the Simplified Chinese delete statement. - } - } - - func testNewGroupTagIsNeutralAndExplained() { - Fixtures.withLanguages { language in - XCTAssertEqual([ApprovalTag.newGroup.title, appLocalized("Created when you approve")], language == "en" - ? ["New group", "Created when you approve"] : ["新分组", "批准后创建"]) // i18n-literal: Assert the Simplified Chinese new-group tag. - XCTAssertEqual(ApprovalTag.newGroup.color, Theme.text, "a new group is not a warning") - XCTAssertEqual(ApprovalTag.removed.color, Theme.warning) - XCTAssertEqual(ApprovalTag.unchanged.color, Theme.textSecondary) - } - } - func testNothingChangedIsSaidPlainly() { Fixtures.withLanguages { language in let same = content(modification(before: [Fixtures.token], after: [Fixtures.token], beforeDigest: "same", afterDigest: "same")) diff --git a/Tests/AskKeyAppTests/LocalizationUnificationTests.swift b/Tests/AskKeyAppTests/LocalizationUnificationTests.swift index 0ad5637..735b7fe 100644 --- a/Tests/AskKeyAppTests/LocalizationUnificationTests.swift +++ b/Tests/AskKeyAppTests/LocalizationUnificationTests.swift @@ -185,7 +185,6 @@ final class LocalizationUnificationTests: AskKeyAppTestCase { "Component list separator": ", ", "Sentence separator": " ", "Clause separator": ", ", - "New group tag": "New group", "Create credential button": "Create", "Removed phrases: %@": "Removed: %@", ] diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index 2a14642..ec269bf 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -81,8 +81,9 @@ final class ScreenshotE2ETests: E2EBaseCase { capture("12-approval-default", approval: true) click("approval-details") waitForText("Requested by") - waitForText("Claude Code (name provided by the requester; Ask Key can't verify it)") - waitForText("Environment variable STAGING_API_TOKEN") + waitForText("Claude Code (not verified)") + waitForText("Hands over") + waitForText("Staging API → environment variable STAGING_API_TOKEN") waitForText("For 30 minutes, any agent or command in your Mac account can read this credential", containing: true) capture("13-approval-details", approval: true) click("approval-details") @@ -112,13 +113,12 @@ final class ScreenshotE2ETests: E2EBaseCase { XCTAssertFalse(app.buttons["approval-reveal-frozen-material"].exists, "values stay behind Details") capture("15-approval-credential-metadata", approval: true) click("approval-details") - waitForText("Items (1, values provided by E2E Agent)") - waitForText("token · 24 bytes · given to programs as environment variable STAGING_TOKEN") + waitForText("Contents") + waitForText("token · given to programs as environment variable STAGING_TOKEN") XCTAssertTrue(app.buttons["approval-reveal-frozen-material"].waitForExistence(timeout: 8)) - waitForText("Instructions for agents") + waitForText("Instructions") waitForText("Use only for staging API requests.", containing: true) - XCTAssertTrue(app.descendants(matching: .any)["approval-new-group"].waitForExistence(timeout: 8)) - waitForText("Its agent permission will be Ask every time", containing: true) + XCTAssertFalse(app.staticTexts["After you approve"].exists, "Details never repeat the subtitle") XCTAssertFalse(app.staticTexts["Before"].exists) XCTAssertFalse(app.staticTexts["After"].exists) XCTAssertFalse(app.staticTexts["Unchanged"].exists)