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/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift index 8578dcc..7f0db1e 100644 --- a/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift +++ b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift @@ -1,79 +1,14 @@ import AppKit import SwiftUI -/// A fixed card section: its heading, an optional status tag and trailing -/// control, then content. The overflow line names it while it is hidden. -struct ApprovalSection: View { - let title: String - let tag: ApprovalTag? - let name: String? - let identifier: String - let accessory: Accessory - let content: Content - - init(title: String, tag: ApprovalTag? = nil, name: String? = nil, identifier: String, - @ViewBuilder accessory: () -> Accessory, @ViewBuilder content: () -> Content) { - self.title = title - self.tag = tag - self.name = name - self.identifier = identifier - self.accessory = accessory() - self.content = content() - } - - var body: some View { - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { - Text(verbatim: title) - .font(Theme.Fonts.caption.weight(.semibold)) - .foregroundStyle(Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - if let tag { ApprovalTagView(tag: tag) } - Spacer(minLength: Theme.Spacing.sm) - accessory - } - content - .font(Theme.Fonts.secondary) - .foregroundStyle(Theme.text) - } - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .approvalScrollMarker(name: name ?? title) - .accessibilityElement(children: .contain) - .accessibilityIdentifier(identifier) - } -} - -extension ApprovalSection where Accessory == EmptyView { - init(title: String, tag: ApprovalTag? = nil, name: String? = nil, identifier: String, - @ViewBuilder content: () -> Content) { - self.init(title: title, tag: tag, name: name, identifier: identifier, accessory: { EmptyView() }, content: content) - } -} - -struct ApprovalTagView: View { - let tag: ApprovalTag - - var body: some View { - Text(verbatim: tag.title) - .font(Theme.Fonts.caption.weight(.semibold)) - .foregroundStyle(tag.color) - .padding(.horizontal, Theme.Spacing.xs) - .padding(.vertical, 1) - .background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Spacing.xs)) - .fixedSize() - } -} - extension ApprovalTag { /// Grey for no change, blue for edits, green for additions, orange for - /// replaced values and merges, red for removals; new groups stay neutral. + /// 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 } @@ -110,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(" ") @@ -129,8 +64,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..da5d5e2 100644 --- a/Sources/AskKeyAppKit/App/ApprovalCopy.swift +++ b/Sources/AskKeyAppKit/App/ApprovalCopy.swift @@ -1,50 +1,21 @@ 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 { 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 { @@ -68,6 +39,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,16 +89,16 @@ struct ApprovalLine: Equatable { } } -/// Small status labels on neutral backgrounds. 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 070abb8..c7450ab 100644 --- a/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift +++ b/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift @@ -1,100 +1,69 @@ import AskKeyBroker -/// Copy shown by the approval card, derived from the pending request. 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 - let monospaced: Bool - } - - 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? 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) } - 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 } - var rows: [Row] = [] - if let directory = display?.workingDirectory, !directory.isEmpty { - rows.append(Row(label: appLocalized("Runs in"), value: directory, monospaced: true)) + 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 } - 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 + let display = request.operation == .read ? request.display : nil + commandSummary = display?.commandSummary + fullCommand = display?.commandLine } - /// 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) - } - - 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.") - } - - /// 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) - 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))]) + /// 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 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) + 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) + } + + private static func sentence(_ format: String, _ arguments: String...) -> String { + EmphasizedSentence(format: format, arguments: arguments).plainText } } diff --git a/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift index 0a70710..f0adc10 100644 --- a/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift +++ b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift @@ -1,88 +1,54 @@ 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 and the edge +/// where lines continue fades out, so no line looks cut in half. 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))]) }) + } + .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) } - .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")) } + } + .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 static var fade: CGFloat { 24 } 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 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) @@ -97,56 +63,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 +80,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 +101,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/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 d56dbe6..b496be5 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,44 @@ 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 details: ApprovalDetailsContent + 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) + 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, details: details, 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 +75,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(presentation.details) + .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,188 +96,100 @@ 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) - } - 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) + /// 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") + } } - .padding(.horizontal, Theme.Spacing.sm) - .padding(.vertical, Theme.Spacing.xs) - .background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Radius.control)) } } - 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 { - Grid(alignment: .topLeading, horizontalSpacing: Theme.Spacing.md, verticalSpacing: Theme.Spacing.sm) { - ForEach(Array(rows.enumerated()), id: \.offset) { _, row in - GridRow { - Text(row.label) - .foregroundStyle(Theme.textSecondary) - .fixedSize() - Text(verbatim: row.value) - .font(row.monospaced ? Theme.Fonts.mono : Theme.Fonts.secondary) - .foregroundStyle(Theme.text) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - .font(Theme.Fonts.secondary) - .id("approval-details-rows") - .accessibilityIdentifier("approval-details-rows") + 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) } + .foregroundStyle(Theme.accent) + .contentShape(Rectangle()) } - .approvalScrollMarker(name: appLocalized("Details")) + .buttonStyle(.plain) + .accessibilityIdentifier("approval-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") - } - if isRead, timedAllowanceEnabled { - explained(timedScope) { - ApprovalPromptButton(title: FrozenApprovalActions.timed(minutes: timedAllowanceMinutes), - role: .secondary) { finish(.timedAllow(duration: nil)) } - .accessibilityIdentifier("approval-allow-timed") - } - } - } - ApprovalPromptButton(title: appLocalized("Deny"), role: .secondary) { finish(.deny) } - .accessibilityIdentifier("approval-deny") + /// 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) } } - 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/FrozenOrganizationSummaryContent.swift similarity index 62% rename from Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift rename to Sources/AskKeyAppKit/App/FrozenOrganizationSummaryContent.swift index 650cdf5..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 @@ -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 { @@ -78,38 +119,3 @@ struct FrozenOrganizationSummaryContent: Equatable { : appLocalizedFormat("It has %@.", ApprovalCopy.members(members, hidden: hidden)) } } - -/// The ordered steps; the card's scrolling body counts them while any are -/// still below the visible part. -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) - .approvalScrollMarker(step: true) - .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 dbf53f8..0000000 --- a/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift +++ /dev/null @@ -1,231 +0,0 @@ -import SwiftUI -import AskKeyBroker - -/// The write card's fixed sections 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 - @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 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) - } 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, name: appLocalized("Items"), 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", unit: .lines(height: 15), maxHeight: 85, - indicatorOffset: Theme.Spacing.sm - 2) { _ in - 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.quote).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 bd950ee..df5cfe2 100644 --- a/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift @@ -5,115 +5,89 @@ 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 - /// 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. - case unchanged + struct GroupChange: Equatable { + let before: String + let after: String + let createsGroup: Bool } 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 - 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 under the primary button. - 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 = componentsChanged ? 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) - } - + self.components = components 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) - changeSummary = modify ? Self.changeSummary(components, instructions: instructionsChanged, group: groupChanged) : nil - 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 { 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 + 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 } } /// 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 { (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 { - return Component(name: after.name, tag: .new, line: line(after), notes: [], - carriesValue: true, overwrites: false, byteCount: after.byteCount, - change: appLocalizedFormat("%@ added", after.name)) + guard let old = before[ApprovalCopy.matchKey(after.name)] else { + return Component(name: after.name, tag: .new, delivery: after.delivery, carriesValue: true) } let valueChanged: Bool if let oldDigest = old.valueDigest, let newDigest = after.valueDigest { @@ -123,75 +97,58 @@ 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, - change: appLocalizedFormat("the value of %@", after.name)) - } - 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, - change: old.delivery != after.delivery - ? appLocalizedFormat("how %@ is given to programs", after.name) - : appLocalizedFormat("the settings of %@", after.name)) + 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(normalized($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)) + rows += summary.before.filter { !afterNames.contains(ApprovalCopy.matchKey($0.name)) }.map { + 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()) - } - - /// 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)) - } - - 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") - } - - private static func normalized(_ name: String) -> String { - name.precomposedStringWithCanonicalMapping - .folding(options: [.caseInsensitive], locale: Locale(identifier: "en_US_POSIX")) + /// 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) } } diff --git a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings index 8d249ba..d0eeb93 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,340 @@ "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$@ → environment variable %2$@": { "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$@ → environment variable %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ → 环境变量 %2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ → environment variable %2$@!!]" } } } }, - "%@ added": { + "%1$@ → temporary file %2$@": { "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$@ → temporary file %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ → 临时文件 %2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ → temporary file %2$@!!]" } } } }, - "%@ removed": { + "%1$@, %2$lld of which merge groups": { "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": "%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!!]" } } } }, - "Below: %@": { + "%1$@, affecting %2$lld hidden credentials": { "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": "%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!!]" } } } }, - "Created when you approve": { + "%@ (new group)": { "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": "%@ (new group)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@(新分组)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ (new group)!!]" } } } }, - "Credential value: %@": { + "%@ (some instruction text is deleted)": { "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": "%@ (some instruction text is deleted)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@(删掉了部分说明)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ (some instruction text is deleted)!!]" } } } }, - "Esc to hide; decide in Pending requests before it expires": { + "%@ (not verified)": { "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": "%@ (not verified)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@(未核实)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ (not verified)!!]" } } } }, - "Expires in %@ and nothing is handed over": { + "%@, affecting 1 hidden credential": { "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": "%@, affecting 1 hidden credential" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@,会动到 1 个隐藏的凭证" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@, affecting 1 hidden credential!!]" } } } }, - "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.": { + "%@, one of which merges groups": { "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": "%@, one of which merges groups" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@,其中一步会合并分组" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@, one of which merges groups!!]" } } } }, - "Hidden value": { + "%@; the old and removed values 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 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!!]" } } } }, - "If you allow, this command receives %1$lld items from %2$@": { + "%@; the old value 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 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!!]" } } } }, - "If you allow, this command receives 1 item from %@": { + "%@; the old values can't be recovered": { "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": "%@; 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!!]" } } } }, - "If you approve, the old value is overwritten and can't be recovered.": { + "%@; the removed value can't be recovered": { "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": "%@; 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!!]" } } } }, - "Items": { + "%@; the removed values can't be recovered": { "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": "%@; 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!!]" } } } }, - "Items (%1$lld, new values provided by %2$@)": { + "%lld steps": { "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": "%lld steps" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "共 %lld 步" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld steps!!]" } } } }, - "Items (%1$lld, values provided by %2$@)": { + "1 step": { "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": "1 step" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "共 1 步" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 step!!]" } } } }, - "Old value %1$@ → new value %2$@ (new value from %3$@)": { + "Apply": { "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": "Apply" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "执行" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Apply!!]" } } } }, - "Removed phrases: %@": { + "Authentication cancelled. Nothing was handed over.": { "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": "Authentication cancelled. Nothing was handed over." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已取消验证,未交出任何内容" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Authentication cancelled. Nothing was handed over.!!]" } } } }, - "Stated purpose": { + "Change": { "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": "Change" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "修改" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Change!!]" } } } }, - "What the command receives": { + "Changes": { "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": "Changes" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "改动" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Changes!!]" } } } }, - "When it's renamed, the group has %@.": { + "Clause separator": { "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 %@.!!]" } } - } - }, - "When it's renamed, the group is empty.": { - "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.!!]" } } - } - }, - "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.": { - "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.!!]" } } - } - }, - "You cancelled authentication. Nothing was given to the command, and the request is still pending.": { - "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.!!]" } } - } - }, - "how %@ is given to programs": { - "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!!]" } } - } - }, - "Change summary: instructions": { - "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!!]" } } - } - }, - "the settings of %@": { - "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": ", " } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "," } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": ", " } } } }, - "the value of %@": { + "Command": { "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": "Command" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "命令" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Command!!]" } } } }, - "→ Environment variable %@": { + "Create credential button": { "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": "Create" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新建" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Create!!]" } } } }, - "→ Temporary file (path in %@, removed within 5 minutes)": { + "Esc to decide later": { "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": "Esc to decide later" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Esc 稍后处理" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Esc to decide later!!]" } } } }, - "Reached the end": { + "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": "Reached the end" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "已经到底" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Reached the end!!]" } } + "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.!!]" } } } }, - "%1$@ (%2$lld hidden from agents)": { + "Hands over": { "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": "Hands over" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "交出" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Hands over!!]" } } } }, - "%1$@ disappears and %2$@ will have %3$@.": { + "Hidden value": { "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": "Hidden value" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已隐藏的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Hidden value!!]" } } } }, - "%1$@ wants to change the credential %2$@": { + "In the group %@": { "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": "In the group %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "放进%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!In the group %@!!]" } } } }, - "%1$@ wants to create the credential %2$@": { + "In the new group %@": { "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": "In the new group %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "放进新分组%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!In the new group %@!!]" } } } }, - "%1$@ wants to delete the credential %2$@": { + "Instructions": { "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": "Instructions" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "说明" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Instructions!!]" } } } }, - "%1$@ wants to reorganize your groups (%2$lld steps)": { + "Moves to the Recycle Bin; restorable for 30 days": { "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": "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 use the credential %2$@": { + "Purpose": { "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": "Purpose" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "用途" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Purpose!!]" } } } }, - "%1$@ wants to use the credential %2$@ to run %3$@": { + "Removed phrases: %@": { "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": "Removed: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "删掉了:%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Removed: %@!!]" } } } }, - "%@ (name provided by the requester; Ask Key can't verify it)": { + "Replace": { "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": "Replace" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "替换" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Replace!!]" } } } }, - "%@ bytes": { + "Step %lld": { "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": "Step %lld" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 步" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Step %lld!!]" } } } }, - "%@ wants to reorganize your groups": { + "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": "%@ 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": "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)!!]" } } } }, - "%@ wants to reorganize your groups (1 step)": { + "The old value can't be recovered": { "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": "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!!]" } } } }, - "%lld more lines — scroll to see them": { + "The old values can't be recovered": { "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": "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!!]" } } } }, - "%lld more steps — scroll to see them": { + "When it's renamed, the group has %@.": { "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": "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 %@.!!]" } } } }, - "1 byte": { + "When it's renamed, the group is empty.": { "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": "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.!!]" } } } }, - "1 more line — scroll to see it": { + "%1$@ (%2$lld hidden from agents)": { "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": "%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)!!]" } } } }, - "1 more step — scroll to see it": { + "%1$@ disappears and %2$@ will have %3$@.": { "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$@ 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$@.!!]" } } } }, "A merge can't be undone automatically.": { @@ -405,42 +365,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": { @@ -449,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": { @@ -465,30 +385,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,22 +401,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": { - "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": { @@ -529,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": { @@ -561,14 +425,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": { @@ -585,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": { @@ -617,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": { @@ -649,14 +481,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,52 +505,60 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": " " } } } }, - "Stated purpose (not verified)": { + "The group is empty.": { "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)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "The group is empty." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "组里没有凭证。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The group is empty.!!]" } } } }, - "Its agent permission will be Ask every time, so agents need your approval for each use.": { + "This group already exists, so nothing is created or changed.": { "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.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "This group already exists, so nothing is created or changed." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这个分组已经存在,不会新建或改动任何内容。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This group already exists, so nothing is created or changed.!!]" } } } }, - "The group is empty.": { + "adds %lld items": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "The group is empty." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "组里没有凭证。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The group is empty.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "adds %lld items" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新增 %lld 项" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!adds %lld items!!]" } } } }, - "The items of %@ that are set to be given to programs (names are shown after you approve)": { + "adds 1 item": { "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)!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "adds 1 item" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新增 1 项" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!adds 1 item!!]" } } } }, - "This group already exists, so nothing is created or changed.": { + "changes %@": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "This group already exists, so nothing is created or changed." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "这个分组已经存在,不会新建或改动任何内容。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This group already exists, so nothing is created or changed.!!]" } } + "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!!]" } } } }, - "Saved as is if you approve. Viewing needs another authentication; it doesn't approve.": { + "changes the settings of 1 item": { "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.!!]" } } + "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 %@)": { @@ -745,14 +577,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 +585,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": { @@ -793,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": { @@ -5540,29 +5348,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 +14228,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 +14283,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 +14330,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 +14966,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/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") + } } 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/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 babd03e..a7144a0 100644 --- a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift +++ b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift @@ -2,204 +2,187 @@ 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"]), + ] + + // 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 testTitlesNameTheObjectInBothLanguages() { + func testEveryCardShowsItsTitleSubtitleAndButtonsInBothLanguages() { + XCTAssertEqual(Set(english.keys), Set(chinese.keys)) 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. - ]) + 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 testNamesKeepTheirQuotesAndWrapAsAWhole() { - 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. + 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 testReadCardShowsTheFullCommandWhatItReceivesAndTheUnverifiedPurpose() { - let longCommand = "./deploy.sh " + (1...30).map { "--flag-\($0) value" }.joined(separator: " ") + func testCancelledAuthenticationKeepsTheSameButtonsAndAddsOneGrayLine() { 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. - ]) + 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) + } + 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 testReadDeliveryRowsCoverFilesMultiItemCredentialsAndNoDelivery() { - 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. - ]) - } - 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") - } + 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 testButtonsStateTheirConsequenceInBothLanguages() { + func testNamesAreQuotedInBothLanguagesAndNeverShortened() { + let name = String(repeating: "Long Credential Name ", count: 12) 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), - ] - 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(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(FrozenApprovalActions.timedScope(minutes: 30), - "30 分钟内,你这个 Mac 账户下的任何 Agent 或命令读取这个凭证都不再询问;修改或删除它仍要你批准。") // i18n-literal: Assert the Simplified 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(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 testCancelledAuthenticationRetriesTheOriginalChoice() { + func testFooterInBothLanguages() { 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), - ] - 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. + 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 testFooterAndOverflowCopyInBothLanguages() { + func testPendingListSaysTheCardTitle() { 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. + 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/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 6cd69b5..3e3a4d8 100644 --- a/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift +++ b/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift @@ -4,8 +4,8 @@ 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: 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 @@ -14,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, @@ -25,92 +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.plain, .current(language == "en" ? "“Release Tools”" : "「Release Tools」")) // i18n-literal: Chinese corner brackets. - XCTAssertTrue(create.createsGroup) - XCTAssertNil(create.changeSummary) - } + 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 testMetadataOnlyChangeNamesWhatChangesAndFoldsTheItems() { - Fixtures.withLanguages { language in - let modify = content(Fixtures.metadataSummary) - XCTAssertTrue(modify.components.isEmpty, "unchanged items fold into the summary line") - XCTAssertTrue(modify.valueComponents.isEmpty) - XCTAssertEqual(modify.instructionsTag, .changed) - XCTAssertEqual(modify.group, .unchanged) - XCTAssertFalse(modify.valueOnlyChange) - 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. - } + 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) - 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.changeSummary, "Changes: the value of TOKEN · Unchanged: instructions for agents, group") - 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.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) - 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. - 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 } @@ -120,16 +90,15 @@ 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"]) + 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.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) @@ -167,59 +136,59 @@ 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. 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"]) } } - func testDeleteShowsWhatIsDeletedAndWhereItGoes() { + func testNothingChangedIsSaidPlainly() { 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.")) - 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. + let same = content(modification(before: [Fixtures.token], after: [Fixtures.token], beforeDigest: "same", afterDigest: "same")) + XCTAssertEqual(same.subtitle, language == "en" ? "Nothing changes" : "不会改动任何内容") // i18n-literal: Assert the Simplified Chinese no-change subtitle. } } - 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 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. } + XCTAssertTrue(content(everything).isDestructive) + XCTAssertFalse(content(everything).valueOnlyChange) + XCTAssertTrue(content(twoValues).valueOnlyChange) } - func testNothingChangedIsSaidPlainly() { + func testCreateSubtitleNamesTheGroupAndOmitsUngrouped() { 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. - } - } -} - -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: " ") - } - 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 + 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..735b7fe 100644 --- a/Tests/AskKeyAppTests/LocalizationUnificationTests.swift +++ b/Tests/AskKeyAppTests/LocalizationUnificationTests.swift @@ -183,12 +183,10 @@ 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": " ", - "New group tag": "New group", - "Credential value: %@": "the value of %@", + "Clause separator": ", ", + "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..ec269bf 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -71,29 +71,30 @@ 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("Claude Code (name provided by the requester; Ask Key can't verify it)") + waitForText("Requested by") + 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") 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 +107,21 @@ 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("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) - waitForText("Instructions for agents") + 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("Contents") + waitForText("token · given to programs as environment variable STAGING_TOKEN") + XCTAssertTrue(app.buttons["approval-reveal-frozen-material"].waitForExistence(timeout: 8)) + waitForText("Instructions") 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["After you approve"].exists, "Details never repeat the subtitle") 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))