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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions Sources/CodexBar/MenuCardRefreshMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@ final class MenuCardRefreshMonitor {
typealias ModelResolver = @MainActor (UsageProvider) -> UsageMenuCardView.Model?

private let resolveModel: ModelResolver
var isManualRefreshInFlight = false
private var manualRefreshProvider: UsageProvider?
/// Set while an all-providers refresh is running; it freezes every provider's card.
private var globalManualRefreshInFlight = false
/// Providers with an individual manual refresh in flight. Concurrent entries are allowed so
/// refreshing one provider does not stall or unfreeze another.
private var manualRefreshProviders: Set<UsageProvider> = []
private var frozenManualRefreshModels: [UsageProvider: UsageMenuCardView.Model] = [:]

/// True while any manual refresh (global or per-provider) is running.
var isManualRefreshInFlight: Bool {
self.globalManualRefreshInFlight || !self.manualRefreshProviders.isEmpty
}

init(resolveModel: @escaping ModelResolver) {
self.resolveModel = resolveModel
}
Expand All @@ -25,19 +33,34 @@ final class MenuCardRefreshMonitor {
frozenModels: [UsageProvider: UsageMenuCardView.Model],
provider: UsageProvider? = nil)
{
self.frozenManualRefreshModels = frozenModels
self.manualRefreshProvider = provider
self.isManualRefreshInFlight = true
if let provider {
self.frozenManualRefreshModels[provider] = frozenModels[provider]
self.manualRefreshProviders.insert(provider)
} else {
self.frozenManualRefreshModels = frozenModels
self.globalManualRefreshInFlight = true
}
}

/// Balances a `beginManualRefresh` with the same `provider` argument (nil ends the global refresh).
func endManualRefresh(for provider: UsageProvider? = nil) {
if let provider {
self.manualRefreshProviders.remove(provider)
self.frozenManualRefreshModels[provider] = nil
} else {
self.globalManualRefreshInFlight = false
self.frozenManualRefreshModels.removeAll(keepingCapacity: true)
}
}

func endManualRefresh() {
self.isManualRefreshInFlight = false
self.manualRefreshProvider = nil
func resetManualRefresh() {
self.globalManualRefreshInFlight = false
self.manualRefreshProviders.removeAll(keepingCapacity: true)
self.frozenManualRefreshModels.removeAll(keepingCapacity: true)
}

func isManualRefreshInFlight(for provider: UsageProvider) -> Bool {
self.isManualRefreshInFlight && (self.manualRefreshProvider == nil || self.manualRefreshProvider == provider)
self.globalManualRefreshInFlight || self.manualRefreshProviders.contains(provider)
}

func model(
Expand Down
26 changes: 20 additions & 6 deletions Sources/CodexBar/StatusItemController+Actions.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import AppKit
import CodexBarCore

extension StatusItemController {
/// Identifies which manual refresh a task belongs to, so per-provider refreshes stay independent
/// of each other and of the all-providers refresh.
enum ManualRefreshScope: Hashable {
case global
case provider(UsageProvider)
}
}

enum LoginNotificationLogic {
static func notificationCopy(providerName: String) -> (title: String, body: String) {
(
Expand Down Expand Up @@ -114,10 +123,17 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
}

private func startManualRefresh(for provider: UsageProvider?) {
let scope: ManualRefreshScope = provider.map(ManualRefreshScope.provider) ?? .global
let scopedRefreshInFlight = provider.map { self.store.refreshingProviders.contains($0) }
?? !self.store.refreshingProviders.isEmpty
// Two different providers may refresh concurrently, but an all-providers (.global) refresh must
// not overlap a per-provider one (or vice versa) — that would duplicate the shared fetch work.
let conflictsWithOtherScope = scope == .global
? self.manualRefreshTasks.contains { $0.key != .global }
: self.manualRefreshTasks[.global] != nil
guard !self.hasPreparedForAppShutdown,
self.manualRefreshTask == nil,
self.manualRefreshTasks[scope] == nil,
Comment thread
hhh2210 marked this conversation as resolved.
!conflictsWithOtherScope,
!self.store.isRefreshing,
!scopedRefreshInFlight
else { return }
Expand All @@ -126,9 +142,8 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer {
self.manualRefreshTask = nil
self.manualRefreshProvider = nil
self.menuCardRefreshMonitor.endManualRefresh()
self.manualRefreshTasks[scope] = nil
self.menuCardRefreshMonitor.endManualRefresh(for: provider)
self.updatePersistentRefreshItemsEnabled()
self.prepareAttachedClosedMenusIfNeeded()
}
Expand All @@ -152,8 +167,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
interaction: .userInitiated)
}
}
self.manualRefreshProvider = provider
self.manualRefreshTask = task
self.manualRefreshTasks[scope] = task
self.menuCardRefreshMonitor.beginManualRefresh(frozenModels: frozenModels, provider: provider)
self.updatePersistentRefreshItemsEnabled()
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/StatusItemController+MenuTracking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ extension StatusItemController {

var isMenuDataRefreshInFlight: Bool {
self.store.isRefreshing ||
self.manualRefreshTask != nil ||
!self.manualRefreshTasks.isEmpty ||
!self.store.refreshingProviders.isEmpty ||
UsageProvider.allCases.contains { self.store.isTokenRefreshInFlight(for: $0) }
}
Expand Down
21 changes: 16 additions & 5 deletions Sources/CodexBar/StatusItemController+PersistentMenuActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,29 @@ extension StatusItemController {
}

func isRefreshActionInFlight(for menu: NSMenu) -> Bool {
if self.manualRefreshTask != nil {
// An all-providers manual refresh (⌘R / overview) legitimately busies every row.
if self.manualRefreshTasks[.global] != nil {
return true
}

if self.isMergedOverviewSelected(in: menu) {
// Overview refresh is global, so its busy state must mirror the global manual-refresh gate.
return self.store.isRefreshing || !self.store.refreshingProviders.isEmpty
// Overview stands for every provider, so it is busy while ANY manual refresh runs —
// including the post-fetch tail of a per-provider refresh, after `refreshingProviders`
// has cleared but its `.provider` task is still finishing status/token/credits work.
return self.store.isRefreshing
|| !self.manualRefreshTasks.isEmpty
|| !self.store.refreshingProviders.isEmpty
}
if let provider = self.menuProvider(for: menu) {
return self.store.isRefreshing || self.store.refreshingProviders.contains(provider)
// A manual refresh of a different provider must not grey out this provider's row: only
// reflect the global refresh, this provider's own manual refresh, and its store refresh.
return self.store.isRefreshing
|| self.manualRefreshTasks[.provider(provider)] != nil
|| self.store.refreshingProviders.contains(provider)
}
return self.store.isRefreshing || !self.store.refreshingProviders.isEmpty
return self.store.isRefreshing
|| !self.manualRefreshTasks.isEmpty
|| !self.store.refreshingProviders.isEmpty
}

func isMergedOverviewSelected(in menu: NSMenu) -> Bool {
Expand Down
9 changes: 5 additions & 4 deletions Sources/CodexBar/StatusItemController+Shutdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ extension StatusItemController {
self.menuBarCountdownRefreshTask = nil
self.loginTask?.cancel()
self.loginTask = nil
self.manualRefreshTask?.cancel()
self.manualRefreshTask = nil
self.manualRefreshProvider = nil
self.menuCardRefreshMonitor.endManualRefresh()
for task in self.manualRefreshTasks.values {
task.cancel()
}
self.manualRefreshTasks.removeAll()
self.menuCardRefreshMonitor.resetManualRefresh()
self.screenChangeVisibilityTask?.cancel()
self.screenChangeVisibilityTask = nil
self.pendingScreenChangePreviousCount = nil
Expand Down
6 changes: 4 additions & 2 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
var fallbackMenu: NSMenu?
var openMenus: [ObjectIdentifier: NSMenu] = [:]
var menuRefreshTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
var manualRefreshTask: Task<Void, Never>?
var manualRefreshProvider: UsageProvider?
/// Manual refreshes tracked per scope so refreshing one provider neither greys out nor blocks
/// a manual refresh of another. `.global` covers the all-providers refresh (⌘R / merged overview).
var manualRefreshTasks: [ManualRefreshScope: Task<Void, Never>] = [:]

var closedMenuRebuildTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
var closedMenuRebuildRequests = MenuRebuildRequestRegistry<ObjectIdentifier>()
var openMenuRebuildTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
Expand Down
2 changes: 1 addition & 1 deletion Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ extension StatusMenuTests {
controller._test_manualRefreshOperation = nil
}
controller.refreshNow()
let task = try #require(controller.manualRefreshTask)
let task = try #require(controller.manualRefreshTasks[.global])

controller.invalidateMenus()
for _ in 0..<40 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ struct StatusMenuMergedOverviewRefreshTests {
}

#expect(requestCount == 0)
#expect(controller.manualRefreshTask == nil)
#expect(controller.manualRefreshProvider == nil)
#expect(controller.manualRefreshTasks.isEmpty)
}

private func makeSettings() -> SettingsStore {
Expand Down
Loading