diff --git a/Sources/AgentBar/Localization/I18n.swift b/Sources/AgentBar/Localization/I18n.swift index 7a5c4ea..d5c9911 100644 --- a/Sources/AgentBar/Localization/I18n.swift +++ b/Sources/AgentBar/Localization/I18n.swift @@ -14,6 +14,7 @@ enum I18n { let loading: String let notRefreshed: String let lastRefreshPrefix: String + let refreshNow: String let aboutUs: String let quit: String let currentSession: String @@ -38,6 +39,10 @@ enum I18n { let missingTokenUsageResult: String let expectedQuotaWindows: String let availableResetCredits: String + let resetCreditDefaultTitle: String + let resetCreditNeverExpires: String + let resetCreditDetailsUnavailable: String + let noAvailableResetCredits: String let todayTokenUsage: String let yesterdayTokenUsage: String let totalTokenUsage: String @@ -144,6 +149,24 @@ enum I18n { } } + func resetCreditExpiresOn(_ dateText: String) -> String { + switch I18n.language { + case .simplifiedChinese: + return "将于 \(dateText) 到期" + case .english: + return "Expires on \(dateText)" + } + } + + func additionalResetCredits(_ count: Int) -> String { + switch I18n.language { + case .simplifiedChinese: + return "另有 \(count) 次重置" + case .english: + return "\(count) more reset\(count == 1 ? "" : "s")" + } + } + func durationTitle(minutes: Int) -> String { switch minutes { case 300: @@ -209,6 +232,7 @@ enum I18n { loading: "loading...", notRefreshed: "Not refreshed", lastRefreshPrefix: "Last refresh", + refreshNow: "Refresh now", aboutUs: "About us", quit: "Quit", currentSession: "Current session", @@ -233,6 +257,10 @@ enum I18n { missingTokenUsageResult: "Missing token usage result.", expectedQuotaWindows: "Expected primary and secondary quota windows.", availableResetCredits: "Available resets:", + resetCreditDefaultTitle: "Full reset (Weekly + 5 hr)", + resetCreditNeverExpires: "Does not expire", + resetCreditDetailsUnavailable: "Details unavailable", + noAvailableResetCredits: "No reset credits available", todayTokenUsage: "Today tokens", yesterdayTokenUsage: "Yesterday tokens", totalTokenUsage: "Total tokens", @@ -250,6 +278,7 @@ enum I18n { loading: "加载中...", notRefreshed: "尚未刷新", lastRefreshPrefix: "上次刷新", + refreshNow: "立即刷新", aboutUs: "关于我们", quit: "退出", currentSession: "当前会话", @@ -274,6 +303,10 @@ enum I18n { missingTokenUsageResult: "缺少 token 消耗结果。", expectedQuotaWindows: "需要 primary 和 secondary 两个额度窗口。", availableResetCredits: "可用重置次数:", + resetCreditDefaultTitle: "完整重置(周 + 5 小时)", + resetCreditNeverExpires: "不会到期", + resetCreditDetailsUnavailable: "暂无重置详情", + noAvailableResetCredits: "暂无可用重置次数", todayTokenUsage: "今日 Token 数", yesterdayTokenUsage: "昨日 Token 数", totalTokenUsage: "累计 Token 数", diff --git a/Sources/AgentBar/Models/QuotaModels.swift b/Sources/AgentBar/Models/QuotaModels.swift index ce57817..a3856ad 100644 --- a/Sources/AgentBar/Models/QuotaModels.swift +++ b/Sources/AgentBar/Models/QuotaModels.swift @@ -10,11 +10,37 @@ struct QuotaWindow: Identifiable, Sendable { let resetsAt: Date? } +struct RateLimitResetCredit: Identifiable, Sendable { + let id: String + let resetType: String + let status: String + let grantedAt: Date? + let expiresAt: Date? + let title: String? + let detailText: String? +} + struct QuotaSnapshot: Sendable { var primary: QuotaWindow var secondary: QuotaWindow var availableResetCredits: Int? + var resetCredits: [RateLimitResetCredit]? var lastUpdated: Date + + func preservingResetCreditDetails(from previous: QuotaSnapshot?) -> QuotaSnapshot { + guard + resetCredits == nil, + availableResetCredits != 0, + previous?.availableResetCredits == availableResetCredits, + let previousCredits = previous?.resetCredits + else { + return self + } + + var merged = self + merged.resetCredits = previousCredits + return merged + } } struct TokenUsageDay: Identifiable, Sendable { diff --git a/Sources/AgentBar/RPC/CodexAppServerClient.swift b/Sources/AgentBar/RPC/CodexAppServerClient.swift index 9470722..dd1b284 100644 --- a/Sources/AgentBar/RPC/CodexAppServerClient.swift +++ b/Sources/AgentBar/RPC/CodexAppServerClient.swift @@ -17,6 +17,7 @@ final class CodexAppServerClient { private var nextRequestID = 1 private var pending: [Int: Completion] = [:] private var initialized = false + private var selectedCodexPath: String? func start(completion: @escaping @Sendable (Result) -> Void) { queue.async { @@ -79,6 +80,7 @@ final class CodexAppServerClient { func checkMinimumResetCreditsVersion(completion: @escaping @Sendable (Result) -> Void) { queue.async { + self.selectedCodexPath = nil guard let codexPath = Self.resolveCodexCLIPath() else { completion(.failure(QuotaError.codexCLINotFound)) return @@ -94,6 +96,7 @@ final class CodexAppServerClient { return } + self.selectedCodexPath = codexPath completion(.success(version)) } catch { completion(.failure(error)) @@ -102,7 +105,7 @@ final class CodexAppServerClient { } private func launchProcess() throws { - guard let codexPath = Self.resolveCodexCLIPath() else { + guard let codexPath = selectedCodexPath ?? Self.resolveCodexCLIPath() else { throw QuotaError.codexCLINotFound } @@ -333,26 +336,7 @@ final class CodexAppServerClient { } private static func resolveCodexCLIPath() -> String? { - let fileManager = FileManager.default - let environmentPath = ProcessInfo.processInfo.environment["PATH"] ?? "" - let pathCandidates = environmentPath - .split(separator: ":") - .map { String($0) + "/codex" } - - var candidates = pathCandidates + [ - "/opt/homebrew/bin/codex", - "/usr/local/bin/codex", - ] - - let home = fileManager.homeDirectoryForCurrentUser.path - let nvmRoot = "\(home)/.nvm/versions/node" - if let versions = try? fileManager.contentsOfDirectory(atPath: nvmRoot) { - candidates += versions - .sorted(by: >) - .map { "\(nvmRoot)/\($0)/bin/codex" } - } - - return candidates.first { fileManager.isExecutableFile(atPath: $0) } + CodexCLIResolver.resolve() } private func prettyPrintedSanitizedJSON(_ object: Any) -> String { diff --git a/Sources/AgentBar/RPC/CodexCLIResolver.swift b/Sources/AgentBar/RPC/CodexCLIResolver.swift new file mode 100644 index 0000000..c5a5b13 --- /dev/null +++ b/Sources/AgentBar/RPC/CodexCLIResolver.swift @@ -0,0 +1,80 @@ +import AppKit +import Foundation + +struct CodexCLIResolver { + private static let codexBundleIdentifier = "com.openai.codex" + private static let bundledAppNames = ["ChatGPT.app", "Codex.app"] + private static let bundledCLIComponents = ["Contents", "Resources", "codex"] + + static func resolve( + fileManager: FileManager = .default, + environmentPath: String? = ProcessInfo.processInfo.environment["PATH"], + homeDirectory: URL? = nil, + registeredApplicationURL: URL? = registeredCodexApplicationURL(), + applicationDirectories: [URL]? = nil, + fallbackBinDirectories: [URL]? = nil + ) -> String? { + let homeDirectory = homeDirectory ?? fileManager.homeDirectoryForCurrentUser + let applicationDirectories = applicationDirectories ?? [ + URL(fileURLWithPath: "/Applications", isDirectory: true), + homeDirectory.appendingPathComponent("Applications", isDirectory: true), + ] + + let registeredApplicationCandidates: [URL] = registeredApplicationURL.map { + [bundledCLIURL(in: $0)] + } ?? [] + let standardApplicationCandidates: [URL] = bundledAppNames.flatMap { appName in + applicationDirectories.map { directory in + bundledCLIURL(in: directory.appendingPathComponent(appName, isDirectory: true)) + } + } + let bundledCandidates = registeredApplicationCandidates + standardApplicationCandidates + + let pathCandidates = (environmentPath ?? "") + .split(separator: ":") + .map { URL(fileURLWithPath: String($0), isDirectory: true).appendingPathComponent("codex") } + + let fallbackBinDirectories = fallbackBinDirectories ?? [ + URL(fileURLWithPath: "/opt/homebrew/bin", isDirectory: true), + URL(fileURLWithPath: "/usr/local/bin", isDirectory: true), + ] + let fallbackCandidates = fallbackBinDirectories.map { $0.appendingPathComponent("codex") } + + let nvmRoot = homeDirectory + .appendingPathComponent(".nvm", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent("node", isDirectory: true) + let nvmCandidates = ((try? fileManager.contentsOfDirectory(atPath: nvmRoot.path)) ?? []) + .sorted(by: >) + .map { + nvmRoot + .appendingPathComponent($0, isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + .appendingPathComponent("codex") + } + + var visitedPaths = Set() + for candidate in bundledCandidates + pathCandidates + fallbackCandidates + nvmCandidates { + let path = candidate.standardizedFileURL.path + guard visitedPaths.insert(path).inserted else { + continue + } + + if fileManager.isExecutableFile(atPath: path) { + return path + } + } + + return nil + } + + private static func registeredCodexApplicationURL() -> URL? { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: codexBundleIdentifier) + } + + private static func bundledCLIURL(in applicationURL: URL) -> URL { + bundledCLIComponents.reduce(applicationURL) { url, component in + url.appendingPathComponent(component) + } + } +} diff --git a/Sources/AgentBar/Services/CodexRateLimitService.swift b/Sources/AgentBar/Services/CodexRateLimitService.swift index d60ac70..281c6d9 100644 --- a/Sources/AgentBar/Services/CodexRateLimitService.swift +++ b/Sources/AgentBar/Services/CodexRateLimitService.swift @@ -16,7 +16,7 @@ struct CodexRateLimitService { } } - private func parseRateLimitResponse(_ response: CodexAppServerClient.JSONDictionary) throws -> QuotaSnapshot { + func parseRateLimitResponse(_ response: CodexAppServerClient.JSONDictionary) throws -> QuotaSnapshot { guard let result = response["result"] as? CodexAppServerClient.JSONDictionary else { throw QuotaError.parsingFailed(I18n.current.missingRateLimitResult) } @@ -40,10 +40,12 @@ struct CodexRateLimitService { } let selected = selectMenuWindows(from: windows) + let resetCredits = resetCreditsSummary(from: result) let snapshot = QuotaSnapshot( primary: selected.primary, secondary: selected.secondary, - availableResetCredits: resetCreditsAvailableCount(from: result), + availableResetCredits: resetCredits.availableCount, + resetCredits: resetCredits.credits, lastUpdated: Date() ) @@ -120,12 +122,70 @@ struct CodexRateLimitService { return nil } - private func resetCreditsAvailableCount(from result: CodexAppServerClient.JSONDictionary) -> Int? { + private func resetCreditsSummary( + from result: CodexAppServerClient.JSONDictionary + ) -> (availableCount: Int?, credits: [RateLimitResetCredit]?) { guard let resetCredits = result["rateLimitResetCredits"] as? CodexAppServerClient.JSONDictionary else { + return (nil, nil) + } + + let availableCount = intValue(resetCredits["availableCount"]) + guard let rawCredits = resetCredits["credits"], !(rawCredits is NSNull) else { + return (availableCount, nil) + } + + guard let creditRows = rawCredits as? [Any] else { + return (availableCount, nil) + } + + let credits = creditRows.enumerated().compactMap { index, rawCredit -> RateLimitResetCredit? in + guard let credit = rawCredit as? CodexAppServerClient.JSONDictionary else { + return nil + } + + let grantedAt = doubleValue(credit["grantedAt"]) + .map(Date.init(timeIntervalSince1970:)) + let expiresAt = doubleValue(credit["expiresAt"]) + .map(Date.init(timeIntervalSince1970:)) + let fallbackID = "reset-credit-\(index)-\(grantedAt?.timeIntervalSince1970 ?? 0)" + + return RateLimitResetCredit( + id: stringValue(credit["id"]) ?? fallbackID, + resetType: stringValue(credit["resetType"]) ?? "unknown", + status: stringValue(credit["status"]) ?? "unknown", + grantedAt: grantedAt, + expiresAt: expiresAt, + title: stringValue(credit["title"]), + detailText: stringValue(credit["description"]) + ) + } + .sorted { lhs, rhs in + switch (lhs.expiresAt, rhs.expiresAt) { + case let (lhsDate?, rhsDate?): + if lhsDate == rhsDate { + return lhs.id < rhs.id + } + + return lhsDate < rhsDate + case (_?, nil): + return true + case (nil, _?): + return false + case (nil, nil): + return lhs.id < rhs.id + } + } + + return (availableCount, credits) + } + + private func stringValue(_ value: Any?) -> String? { + guard let value = value as? String else { return nil } - return intValue(resetCredits["availableCount"]) + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed } private func doubleValue(_ value: Any?) -> Double? { @@ -184,6 +244,7 @@ struct CodexRateLimitService { "primary": printableWindow(snapshot.primary, formatter: formatter), "secondary": printableWindow(snapshot.secondary, formatter: formatter), "availableResetCredits": snapshot.availableResetCredits ?? NSNull(), + "resetCredits": snapshot.resetCredits?.map { printableResetCredit($0, formatter: formatter) } ?? NSNull(), "lastUpdated": formatter.string(from: snapshot.lastUpdated), ] @@ -203,4 +264,19 @@ struct CodexRateLimitService { "resetsAt": window.resetsAt.map { formatter.string(from: $0) } ?? NSNull(), ] } + + private func printableResetCredit( + _ credit: RateLimitResetCredit, + formatter: ISO8601DateFormatter + ) -> [String: Any] { + [ + "id": credit.id, + "resetType": credit.resetType, + "status": credit.status, + "grantedAt": credit.grantedAt.map { formatter.string(from: $0) } ?? NSNull(), + "expiresAt": credit.expiresAt.map { formatter.string(from: $0) } ?? NSNull(), + "title": credit.title ?? NSNull(), + "description": credit.detailText ?? NSNull(), + ] + } } diff --git a/Sources/AgentBar/Store/QuotaStore.swift b/Sources/AgentBar/Store/QuotaStore.swift index 9a1d584..3018d04 100644 --- a/Sources/AgentBar/Store/QuotaStore.swift +++ b/Sources/AgentBar/Store/QuotaStore.swift @@ -80,6 +80,15 @@ final class QuotaStore: ObservableObject { client.stop() } + func refresh() { + guard hasStarted, !isLoading else { + return + } + + refreshModelReason() + refreshAccountAndRateLimits() + } + private func refreshModelReason() { radarService.readModelReason { [weak self] result in Task { @MainActor in @@ -241,7 +250,7 @@ final class QuotaStore: ObservableObject { switch result { case .success(let snapshot): - self.snapshot = snapshot + self.snapshot = snapshot.preservingResetCreditDetails(from: self.snapshot) self.readTokenUsage(account: account) case .failure(let error): self.apply(error: error) diff --git a/Sources/AgentBar/UI/QuotaPopoverView.swift b/Sources/AgentBar/UI/QuotaPopoverView.swift index c38a3a0..d65fc88 100644 --- a/Sources/AgentBar/UI/QuotaPopoverView.swift +++ b/Sources/AgentBar/UI/QuotaPopoverView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct QuotaPopoverView: View { @@ -6,34 +7,47 @@ struct QuotaPopoverView: View { let onQuit: () -> Void var body: some View { - VStack(spacing: 10) { - AccountInfoHeader(account: store.currentAccount) + VStack(spacing: 0) { + AccountInfoHeader( + account: store.currentAccount, + isRefreshing: store.isLoading, + onRefresh: store.refresh + ) .padding(.horizontal, QuotaPopoverLayout.horizontalPadding) + .padding(.bottom, 10) Divider() .padding(.horizontal, QuotaPopoverLayout.horizontalPadding) content .frame(maxHeight: .infinity, alignment: .top) .padding(.horizontal, QuotaPopoverLayout.horizontalPadding) + .padding(.top, 4) + .padding(.bottom, 10) Divider() .padding(.horizontal, QuotaPopoverLayout.horizontalPadding) footer + .padding(.top, 10) } .padding(.top, QuotaPopoverLayout.topPadding) .padding(.bottom, 16) .frame(width: 320) - .frame(height: 640) + .frame(height: QuotaPopoverLayout.popoverHeight) .background(Color.clear) } @ViewBuilder private var content: some View { if let snapshot = store.snapshot { - VStack(spacing: 14) { - ResetCreditsRow(availableCount: snapshot.availableResetCredits) + VStack(spacing: 0) { + ResetCreditsRow( + availableCount: snapshot.availableResetCredits, + credits: snapshot.resetCredits + ) Divider() + .padding(.top, 4) + .padding(.bottom, 10) VStack(spacing: 14) { QuotaRow(window: snapshot.primary) @@ -42,6 +56,7 @@ struct QuotaPopoverView: View { } Divider() + .padding(.vertical, 10) TokenUsageSectionView( snapshot: store.tokenUsageSnapshot, @@ -112,6 +127,7 @@ struct QuotaPopoverView: View { } private enum QuotaPopoverLayout { + static let popoverHeight: CGFloat = 624 static let topPadding: CGFloat = 22 static let horizontalPadding: CGFloat = 18 static let menuHighlightMargin: CGFloat = 5 @@ -165,6 +181,8 @@ private struct FooterActionButton: View { private struct AccountInfoHeader: View { let account: CodexAccount? + let isRefreshing: Bool + let onRefresh: () -> Void var body: some View { HStack(spacing: 12) { @@ -183,6 +201,8 @@ private struct AccountInfoHeader: View { Spacer(minLength: 8) + RefreshButton(isRefreshing: isRefreshing, action: onRefresh) + RoleBadge(role: displayRole) .fixedSize() } @@ -214,6 +234,37 @@ private struct AccountInfoHeader: View { } } +private struct RefreshButton: View { + let isRefreshing: Bool + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(isHovering ? QuotaPopoverColors.menuSelectionText : QuotaPopoverColors.scaleText) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(isHovering ? QuotaPopoverColors.menuSelectionBackground : Color.clear) + } + .contentShape(Rectangle()) + .onHover { hovering in + isHovering = hovering + } + .disabled(isRefreshing) + .opacity(isRefreshing ? 0.55 : 1) + .help(I18n.current.refreshNow) + .accessibilityLabel(I18n.current.refreshNow) + .animation(.easeOut(duration: 0.08), value: isHovering) + } +} + private struct RoleBadge: View { let role: String @@ -242,44 +293,460 @@ private struct RoleBadge: View { private struct ResetCreditsRow: View { let availableCount: Int? + let credits: [RateLimitResetCredit]? + + @State private var isHovering = false var body: some View { - HStack(spacing: 12) { + HStack(spacing: 8) { Text(I18n.current.availableResetCredits) .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(QuotaPopoverColors.titleText) + .foregroundStyle(titleColor) Spacer(minLength: 8) - ResetCreditsBadge(text: displayCount) + ResetCreditsBadge(text: displayCount, isHighlighted: isHovering) .fixedSize() + + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(trailingColor) } + .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isHovering ? QuotaPopoverColors.menuSelectionBackground : Color.clear) + .padding(.horizontal, -10) + } + .contentShape(Rectangle()) + .overlay { + ResetCreditsHoverMenuPresenter( + availableCount: availableCount, + credits: credits, + isHovering: $isHovering + ) + .padding(.horizontal, -10) + } + .animation(.easeOut(duration: 0.08), value: isHovering) } private var displayCount: String { availableCount.map(String.init) ?? "--" } + + private var titleColor: Color { + isHovering ? QuotaPopoverColors.menuSelectionText : QuotaPopoverColors.titleText + } + + private var trailingColor: Color { + isHovering ? QuotaPopoverColors.menuSelectionText : QuotaPopoverColors.scaleText + } } private struct ResetCreditsBadge: View { let text: String + let isHighlighted: Bool var body: some View { Text(text) .font(.system(size: 11, weight: .bold, design: .rounded)) - .foregroundStyle(QuotaPopoverColors.indigoText) + .foregroundStyle(isHighlighted ? QuotaPopoverColors.menuSelectionText : QuotaPopoverColors.indigoText) .monospacedDigit() .padding(.horizontal, 8) .padding(.vertical, 3) .background { Capsule() - .fill(QuotaPopoverColors.indigoBackground) + .fill(isHighlighted ? Color.white.opacity(0.18) : QuotaPopoverColors.indigoBackground) } .overlay { Capsule() - .stroke(QuotaPopoverColors.indigoBorder, lineWidth: 1) + .stroke( + isHighlighted ? Color.white.opacity(0.5) : QuotaPopoverColors.indigoBorder, + lineWidth: 1 + ) + } + } +} + +private struct ResetCreditsHoverMenuPresenter: NSViewRepresentable { + let availableCount: Int? + let credits: [RateLimitResetCredit]? + @Binding var isHovering: Bool + + func makeNSView(context: Context) -> ResetCreditsHoverAnchorView { + let view = ResetCreditsHoverAnchorView() + view.onHoverChanged = { hovering in + isHovering = hovering + } + return view + } + + func updateNSView(_ nsView: ResetCreditsHoverAnchorView, context: Context) { + nsView.configure(availableCount: availableCount, credits: credits) + nsView.onHoverChanged = { hovering in + if isHovering != hovering { + isHovering = hovering } + } + } + + static func dismantleNSView(_ nsView: ResetCreditsHoverAnchorView, coordinator: ()) { + nsView.dismissMenu() + } +} + +private final class ResetCreditsHoverAnchorView: NSView { + var availableCount: Int? + var credits: [RateLimitResetCredit]? + var onHoverChanged: ((Bool) -> Void)? + + private var hoverTrackingArea: NSTrackingArea? + private var presentedMenu: NSMenu? + private var pointerTrackingTimer: Timer? + private var pointerOutsideSince: Date? + private var isSchedulingPresentation = false + private var scheduledPresentationRequiresPointerInside = true + private var scheduledPresentationTracksPointer = true + + override var isFlipped: Bool { + true + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setAccessibilityElement(true) + setAccessibilityRole(.button) + setAccessibilityLabel(I18n.current.availableResetCredits) + } + + required init?(coder: NSCoder) { + nil + } + + override var acceptsFirstResponder: Bool { + true + } + + func configure(availableCount: Int?, credits: [RateLimitResetCredit]?) { + self.availableCount = availableCount + self.credits = credits + setAccessibilityValue(availableCount.map(String.init) ?? "--") + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + if let hoverTrackingArea { + removeTrackingArea(hoverTrackingArea) + } + + let trackingArea = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea) + hoverTrackingArea = trackingArea + } + + override func mouseEntered(with event: NSEvent) { + pointerOutsideSince = nil + onHoverChanged?(true) + scheduleMenuPresentation(requiresPointerInside: true, tracksPointer: true) + } + + override func mouseDown(with event: NSEvent) { + scheduleMenuPresentation(requiresPointerInside: false, tracksPointer: true) + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 36 || event.keyCode == 49 { + scheduleMenuPresentation(requiresPointerInside: false, tracksPointer: false) + return + } + + super.keyDown(with: event) + } + + private func scheduleMenuPresentation(requiresPointerInside: Bool, tracksPointer: Bool) { + guard presentedMenu == nil, !isSchedulingPresentation else { + return + } + + isSchedulingPresentation = true + scheduledPresentationRequiresPointerInside = requiresPointerInside + scheduledPresentationTracksPointer = tracksPointer + let delay = requiresPointerInside ? 0.12 : 0 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.presentMenuIfPointerIsStillInside() + } + } + + override func mouseExited(with event: NSEvent) { + onHoverChanged?(false) + } + + func dismissMenu() { + isSchedulingPresentation = false + stopPointerMonitoring() + presentedMenu?.cancelTrackingWithoutAnimation() + presentedMenu = nil + } + + private func presentMenuIfPointerIsStillInside() { + isSchedulingPresentation = false + + guard window != nil, !scheduledPresentationRequiresPointerInside || isPointerInside else { + onHoverChanged?(false) + return + } + + let menu = makeDetailsMenu() + presentedMenu = menu + if scheduledPresentationTracksPointer { + startPointerMonitoring() + } + + _ = menu.popUp( + positioning: nil, + at: NSPoint(x: bounds.maxX - 2, y: bounds.minY), + in: self + ) + + stopPointerMonitoring() + presentedMenu = nil + onHoverChanged?(isPointerInside) + } + + private func startPointerMonitoring() { + stopPointerMonitoring() + pointerOutsideSince = nil + + let timer = Timer( + timeInterval: 0.05, + target: self, + selector: #selector(pollPointerLocation(_:)), + userInfo: nil, + repeats: true + ) + pointerTrackingTimer = timer + RunLoop.main.add(timer, forMode: .eventTracking) + RunLoop.main.add(timer, forMode: .common) + } + + private func stopPointerMonitoring() { + pointerTrackingTimer?.invalidate() + pointerTrackingTimer = nil + pointerOutsideSince = nil + } + + @objc private func pollPointerLocation(_ timer: Timer) { + let pointerIsInsideAnchor = isPointerInside + onHoverChanged?(pointerIsInsideAnchor) + + if pointerIsInsideAnchor || isPointerInsidePresentedMenu { + pointerOutsideSince = nil + return + } + + let now = Date() + guard let pointerOutsideSince else { + self.pointerOutsideSince = now + return + } + + guard now.timeIntervalSince(pointerOutsideSince) >= 0.12 else { + return + } + + presentedMenu?.cancelTrackingWithoutAnimation() + } + + private var isPointerInside: Bool { + guard let window else { + return false + } + + let pointInWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation) + let pointInView = convert(pointInWindow, from: nil) + return bounds.contains(pointInView) + } + + private var isPointerInsidePresentedMenu: Bool { + guard + let menuWindow = presentedMenu?.items + .compactMap({ $0.view?.window }) + .first + else { + return false + } + + return menuWindow.frame.contains(NSEvent.mouseLocation) + } + + private func makeDetailsMenu() -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + menu.minimumWidth = ResetCreditsMenuLayout.width + + let item = NSMenuItem() + let rootView = ResetCreditsDetailMenuView( + availableCount: availableCount, + credits: credits + ) + let hostingView = NSHostingView(rootView: rootView) + hostingView.frame = NSRect( + x: 0, + y: 0, + width: ResetCreditsMenuLayout.width, + height: ResetCreditsMenuLayout.height( + availableCount: availableCount, + credits: credits + ) + ) + item.view = hostingView + menu.addItem(item) + + return menu + } +} + +private enum ResetCreditsMenuLayout { + static let width: CGFloat = 210 + static let rowHeight: CGFloat = 68 + static let maxVisibleRows = 6 + + static func height( + availableCount: Int?, + credits: [RateLimitResetCredit]? + ) -> CGFloat { + let detailCount = credits?.count ?? 0 + let expectedCount = max(availableCount ?? detailCount, detailCount) + let missingDetailCount = max(0, expectedCount - detailCount) + let rowCount = max(1, detailCount + (missingDetailCount > 0 ? 1 : 0)) + return rowHeight * CGFloat(min(rowCount, maxVisibleRows)) + } +} + +private struct ResetCreditMenuRow: Identifiable { + let id: String + let title: String + let subtitle: String? +} + +private struct ResetCreditsDetailMenuView: View { + let availableCount: Int? + let credits: [RateLimitResetCredit]? + + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + ResetCreditDetailRow(row: row) + + if index < rows.count - 1 { + Divider() + } + } + } + } + .frame( + width: ResetCreditsMenuLayout.width, + height: ResetCreditsMenuLayout.height( + availableCount: availableCount, + credits: credits + ) + ) + .background(Color.clear) + } + + private var rows: [ResetCreditMenuRow] { + guard let credits else { + return [missingDetailsRow(count: availableCount)] + } + + var rows = credits.map { credit in + ResetCreditMenuRow( + id: credit.id, + title: credit.title ?? I18n.current.resetCreditDefaultTitle, + subtitle: credit.expiresAt.map(expirationText) ?? I18n.current.resetCreditNeverExpires + ) + } + + let missingDetailCount = max(0, (availableCount ?? credits.count) - credits.count) + if missingDetailCount > 0 { + rows.append(missingDetailsRow(count: missingDetailCount)) + } + + if rows.isEmpty { + rows.append( + ResetCreditMenuRow( + id: "no-reset-credits", + title: I18n.current.noAvailableResetCredits, + subtitle: nil + ) + ) + } + + return rows + } + + private func missingDetailsRow(count: Int?) -> ResetCreditMenuRow { + let title: String + let subtitle: String? + + switch count { + case let count? where count > 0: + title = I18n.current.additionalResetCredits(count) + subtitle = I18n.current.resetCreditDetailsUnavailable + case 0?: + title = I18n.current.noAvailableResetCredits + subtitle = nil + case nil: + title = I18n.current.resetCreditDetailsUnavailable + subtitle = nil + default: + title = I18n.current.resetCreditDetailsUnavailable + subtitle = nil + } + + return ResetCreditMenuRow( + id: "missing-reset-credit-details-\(count ?? 0)", + title: title, + subtitle: subtitle + ) + } + + private func expirationText(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: I18n.current.dateLocaleIdentifier) + formatter.dateFormat = "M/d" + return I18n.current.resetCreditExpiresOn(formatter.string(from: date)) + } +} + +private struct ResetCreditDetailRow: View { + let row: ResetCreditMenuRow + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(row.title) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(QuotaPopoverColors.primaryText) + .lineLimit(1) + .truncationMode(.tail) + + if let subtitle = row.subtitle { + Text(subtitle) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(QuotaPopoverColors.mutedText) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, minHeight: ResetCreditsMenuLayout.rowHeight, alignment: .leading) + .padding(.horizontal, 16) } }