codeburn/mac/Sources/CodeBurnMenubar/Data/CopilotQuotaPresentation.swift
ozymandiashh a208c25f86
Some checks are pending
CI / semgrep (push) Waiting to run
macOS Menubar CI / test (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
fix(menubar): pin multi-argument catalog keys to positional specifiers
The catalog comparison matched specifier lists, so a key whose
placeholders are all %@ passed even when the Chinese value bound the
arguments into the wrong slots — the drift that shipped in three
early-reset keys and was only caught by rendering real data bilingually
(#1331).

All 42 multi-argument keys in both tables now use positional specifiers
(%1$@, %2$lld, ...); the parity check compares slot to verb so Chinese
word order stays free while each slot keeps its type; a new test fails
on any multi-argument key that is not positional.

The source scanner closes its three expression gaps: ternary branches,
concatenated literals, and let-bound literals passed by name.
Text(verbatim:) stays the explicit opt-out; the four provider-name
fallbacks the new rules surfaced in HeatmapSection route through the
catalog as verbatim entries.

Closes #1331
2026-09-15 01:33:28 +03:00

116 lines
5.6 KiB
Swift

import Foundation
/// Pure display-decision helpers for the Copilot quota surfaces.
///
/// Copilot tokens are read straight from whichever signed-in client already
/// holds one, with no refresh path, so a revoked token sits in
/// `.terminalFailure` until the user signs in again. Like Kimi and
/// Gemini, the always-visible surfaces keep showing the last good snapshot
/// with a quiet caption instead of flapping to a reconnect screen; the
/// reconnect screen is reserved for the no-data case, where there is
/// genuinely nothing to show.
///
/// Explicit Disconnect is different from missing credentials: the store keeps
/// `.notBootstrapped` while credentials stay on disk. Plan, Settings, and
/// Capacity Dock nil-quota copy therefore take the persisted opt-out flag and
/// must not claim the token is gone.
enum CopilotQuotaPresentation {
/// Which Plan-tab subview to render, given the load state and whether a
/// last-known snapshot exists.
enum PlanContent: Equatable {
case noCredentials
/// Explicit Disconnect: quota tracking is off, credentials untouched.
case disconnected
case loading
case failed
case transientFailed
case reconnect(reason: String?)
/// Render the loaded usage bars. `idle` is true when the login has
/// gone terminal but a snapshot is still on hand — the caller stamps a
/// quiet "sign in again" caption instead of hiding the data.
case usage(idle: Bool)
}
static var noCredentialsPlanTitle: String { L("No Copilot credentials found") }
static var noCredentialsPlanMessage: String {
L("Sign in via an editor's Copilot plugin first. Then click Try Again.")
}
static var disconnectedPlanTitle: String { L("Copilot quota tracking disconnected") }
static var disconnectedPlanMessage: String {
L("Your Copilot credentials are untouched. Click Connect to resume.")
}
static var noCredentialsSettingsDetail: String {
L("Usage tracking still works. For live quota, sign in with the Copilot CLI or gh auth login, or paste a token below, then click Connect.")
}
static var disconnectedSettingsDetail: String {
L("Quota tracking disconnected. Credentials are untouched. Click Connect to resume.")
}
static func planContent(
loadState: SubscriptionLoadState,
hasUsage: Bool,
explicitlyDisconnected: Bool = false
) -> PlanContent {
switch loadState {
case .notBootstrapped:
return explicitlyDisconnected ? .disconnected : .noCredentials
case .noCredentials:
return .noCredentials
case .dormant, .bootstrapping:
return .loading
case .loading, .loaded:
return hasUsage ? .usage(idle: false) : .loading
case .failed:
return .failed
case .transientFailure:
return hasUsage ? .usage(idle: false) : .transientFailed
case .terminalFailure(let reason):
return hasUsage ? .usage(idle: true) : .reconnect(reason: reason)
}
}
/// Settings connection-row detail for a loaded snapshot. It always names
/// the host that answered, so a GitHub Enterprise Cloud tenant can see its
/// own `api.<tenant>.ghe.com` endpoint rather than a dotcom claim (#1286).
static func connectedSettingsDetail(plan: String?, apiHost: String) -> String {
let host = apiHost.isEmpty ? CopilotHostEndpoint.defaultAPIHost : apiHost
// The host is an API hostname and the plan comes from GitHub; both are
// substituted verbatim, only the sentence around them is translated.
guard let plan, !plan.isEmpty else { return L("Live quota tracked from %@.", host) }
return L("Plan: %1$@. Live quota tracked from %2$@.", plan, host)
}
static func settingsNotConnectedDetail(explicitlyDisconnected: Bool) -> String {
explicitlyDisconnected ? disconnectedSettingsDetail : noCredentialsSettingsDetail
}
/// Settings connection-row detail before anything has been fetched. It
/// used to promise `api.github.com`, which is wrong for every enterprise
/// tenant now that each rung can carry its own host (#1306): with no
/// answer yet there is no host to name, so it names one only when a
/// previous snapshot already proved which host answers.
static func dormantSettingsDetail(apiHost: String?) -> String {
guard let apiHost, !apiHost.isEmpty, apiHost != CopilotHostEndpoint.defaultAPIHost else {
return L("Tap Load Quota to fetch live usage from GitHub.")
}
return L("Tap Load Quota to fetch live usage from %@.", apiHost)
}
/// Why a host typed next to the pasted token cannot be used, or nil when
/// it can. Rejecting it in Settings turns what would otherwise be a
/// terminal fetch failure into an answer at the field the user typed —
/// and keeps an unaddressable host out of the Keychain record entirely.
static func pastedHostRejection(_ raw: String) -> String? {
let host = CopilotHostEndpoint.normalize(raw) ?? CopilotHostEndpoint.defaultHost
guard CopilotHostEndpoint.apiHost(for: host) == nil else { return nil }
return L("CodeBurn cannot read Copilot quota for %@. Use github.com or a GitHub Enterprise Cloud host (*.ghe.com).", host)
}
/// Snapshot age past which a loaded view stamps an "as of <time>" caption,
/// so a bar can never silently masquerade as current.
static let stalenessThreshold: TimeInterval = 10 * 60
static func isStale(fetchedAt: Date, now: Date = Date()) -> Bool {
now.timeIntervalSince(fetchedAt) > stalenessThreshold
}
}