diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b33d6eb4e..5a023860b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). +- Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl! - Development: disable Keychain access for unbundled executables to avoid repeated password prompts while preserving packaged app behavior (#1271). Thanks @Yuxin-Qiao! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 4a5158a130..4740f03360 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -387,88 +387,15 @@ extension StatusItemController { return reusableRows } - /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. - private struct MenuUpdateContext { - let provider: UsageProvider? - let currentProvider: UsageProvider - let switcherSelection: ProviderSwitcherSelection - let menuWidth: CGFloat - let codexAccountDisplay: CodexAccountMenuDisplay? - let tokenAccountDisplay: TokenAccountMenuDisplay? - let openAIContext: OpenAIWebContext - let descriptor: MenuDescriptor - } - - /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. - private func updateMenuContentPreservingSwitcher( - _ menu: NSMenu, - context: MenuUpdateContext) - { - self.performMenuMutationWithoutAnimation { - let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) - if let switcherView = menu.items.first?.view as? ProviderSwitcherView { - switcherView.updateSelection(context.switcherSelection) - switcherView.updateQuotaIndicators() - } - if let outgoingSelection = self.lastMergedMenuContentSelection, - outgoingSelection != context.switcherSelection - { - self.cacheVisibleMergedSwitcherContent( - in: menu, - selection: outgoingSelection, - contentStartIndex: contentStartIndex, - menuWidth: context.menuWidth) - } - while menu.items.count > contentStartIndex { - menu.removeItem(at: contentStartIndex) - } - - let enabledProviders = self.store.enabledProvidersForDisplay() - self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) - if self.addCachedMergedSwitcherContent( - for: context.switcherSelection, - to: menu, - menuWidth: context.menuWidth, - codexAccountDisplay: context.codexAccountDisplay, - tokenAccountDisplay: context.tokenAccountDisplay) - { - return - } - self.addCodexAccountSwitcherIfNeeded( - to: menu, - display: context.codexAccountDisplay, - width: context.menuWidth) - self.lastCodexAccountMenuDisplay = context.codexAccountDisplay - self.addTokenAccountSwitcherIfNeeded( - to: menu, - display: context.tokenAccountDisplay, - width: context.menuWidth) - self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay - - let menuContext = MenuCardContext( - currentProvider: context.currentProvider, - selectedProvider: context.provider, - menuWidth: context.menuWidth, - codexAccountDisplay: context.codexAccountDisplay, - tokenAccountDisplay: context.tokenAccountDisplay, - openAIContext: context.openAIContext) - self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection) - self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) - self.cacheVisibleMergedSwitcherContent( - in: menu, - selection: context.switcherSelection, - contentStartIndex: contentStartIndex, - menuWidth: context.menuWidth, - contentVersion: self.menuContentVersion) - } - } - private func rebuildMenuContent( _ menu: NSMenu, context: MenuRebuildContext) { self.performMenuMutationWithoutAnimation { + let displacedSelection = self.lastMergedMenuContentSelection self.lastMergedMenuContentSelection = nil + self.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: displacedSelection) + defer { self.clearMenuCardViewRecyclePool() } menu.removeAllItems() let contentSelection = context.switcherSelection ?? .provider(context.currentProvider) self.addProviderSwitcherIfNeeded( @@ -567,16 +494,32 @@ extension StatusItemController { menu.addItem(.separator()) } - private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?, width: CGFloat) { + func addTokenAccountSwitcherIfNeeded( + to menu: NSMenu, + display: TokenAccountMenuDisplay?, + width: CGFloat, + captureMenu: NSMenu? = nil) + { guard let display, display.showSwitcher else { return } - let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu, width: width) + let switcherItem = self.makeTokenAccountSwitcherItem( + display: display, + menu: captureMenu ?? menu, + width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } - private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?, width: CGFloat) { + func addCodexAccountSwitcherIfNeeded( + to menu: NSMenu, + display: CodexAccountMenuDisplay?, + width: CGFloat, + captureMenu: NSMenu? = nil) + { guard let display, display.showSwitcher else { return } - let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu, width: width) + let switcherItem = self.makeCodexAccountSwitcherItem( + display: display, + menu: captureMenu ?? menu, + width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } @@ -585,8 +528,12 @@ extension StatusItemController { private func addOverviewRows( to menu: NSMenu, enabledProviders: [UsageProvider], - menuWidth: CGFloat) -> Bool + menuWidth: CGFloat, + captureMenu: NSMenu? = nil) -> Bool { + // Rows may be built into a detached scratch menu for in-place reconciliation; + // interaction closures must always reference the live menu they end up serving. + let interactionMenu = captureMenu ?? menu let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( activeProviders: enabledProviders) let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders @@ -616,9 +563,9 @@ extension StatusItemController { section: "overview", additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]), submenu: submenu, - onClick: { [weak self, weak menu] in - guard let self, let menu else { return } - self.selectOverviewProvider(row.provider, menu: menu) + onClick: { [weak self, weak interactionMenu] in + guard let self, let interactionMenu else { return } + self.selectOverviewProvider(row.provider, menu: interactionMenu) }) if submenu == nil { // Keep plain rows wired for keyboard activation and accessibility action paths. @@ -768,17 +715,19 @@ extension StatusItemController { menu.addItem(.separator()) } - private func addPrimaryMenuContent( + func addPrimaryMenuContent( to menu: NSMenu, context: MenuCardContext, - switcherSelection: ProviderSwitcherSelection) + switcherSelection: ProviderSwitcherSelection, + captureMenu: NSMenu? = nil) { if switcherSelection == .overview { let enabledProviders = self.store.enabledProvidersForDisplay() if self.addOverviewRows( to: menu, enabledProviders: enabledProviders, - menuWidth: context.menuWidth) + menuWidth: context.menuWidth, + captureMenu: captureMenu) { menu.addItem(.separator()) } else { @@ -809,7 +758,12 @@ extension StatusItemController { } } - func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) { + func addActionableSections( + _ sections: [MenuDescriptor.Section], + to menu: NSMenu, + width: CGFloat, + captureMenu: NSMenu? = nil) + { let actionableSections = sections.filter { section in section.entries.contains { entry in if case .action = entry { return true } @@ -843,7 +797,7 @@ extension StatusItemController { menu.addItem(self.makePersistentMenuActionItem( title: localizedTitle, action: action, - menu: menu, + menu: captureMenu ?? menu, width: width)) continue } diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift index 768b0fcf82..ebb867590f 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardItems.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -20,8 +20,8 @@ extension StatusItemController { } } - func makeMenuCardItem( - _ view: some View, + func makeMenuCardItem( + _ view: CardContent, id: String, width: CGFloat, heightCacheScope: String? = nil, @@ -43,16 +43,33 @@ extension StatusItemController { return item } - let highlightState = MenuCardHighlightState() - let wrapped = MenuCardSectionContainerView( - highlightState: highlightState, - showsSubmenuIndicator: submenu != nil, - submenuIndicatorAlignment: submenuIndicatorAlignment, - submenuIndicatorTopPadding: submenuIndicatorTopPadding) + let hosting: MenuCardItemHostingView> + if let recycled = self.takeRecyclableMenuCardView( + for: id, + as: MenuCardItemHostingView>.self) { - view + let wrapped = MenuCardSectionContainerView( + highlightState: recycled.highlightState, + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding) + { + view + } + recycled.prepareForReuse(rootView: wrapped, onClick: onClick) + hosting = recycled + } else { + let highlightState = MenuCardHighlightState() + let wrapped = MenuCardSectionContainerView( + highlightState: highlightState, + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding) + { + view + } + hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) } - let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) let height = self.cachedMenuCardHeight( for: id, scope: heightCacheScope ?? id, diff --git a/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift b/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift new file mode 100644 index 0000000000..718e2df15f --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift @@ -0,0 +1,69 @@ +import AppKit + +extension StatusItemController { + /// Collects the card hosting views of items the current populate pass is about to discard + /// so `makeMenuCardItem` can reuse them for cards with the same identifier (or, failing + /// that, the same content type) instead of building fresh hosting views. + /// + /// Safety: live menu items can alias one merged-switcher cache entry — the one for the + /// selection currently displayed, re-cached at the end of every populate. Consuming that + /// entry up front (`displacedSelection`) guarantees no cache entry can still reference a + /// harvested view; entries for other selections only hold items already detached from the + /// menu. Harvested views are detached from their outgoing items; whatever the pass does + /// not consume is released by `clearMenuCardViewRecyclePool`. + func harvestRecyclableMenuCardViews( + in menu: NSMenu, + fromIndex: Int, + displacedSelection: ProviderSwitcherSelection?, + preserveHighlightedItem: Bool = false) + { + self.menuCardViewRecyclePool.removeAll(keepingCapacity: true) + let menuKey = ObjectIdentifier(menu) + if let displacedSelection { + self.mergedSwitcherContentCaches[menuKey]?.removeValue(forKey: displacedSelection) + } + guard Self.menuCardRenderingEnabled else { return } + guard fromIndex >= 0, fromIndex < menu.items.count else { return } + for item in menu.items[fromIndex...] { + guard let id = item.representedObject as? String else { continue } + guard let view = item.view, view is any MenuCardMeasuring else { continue } + guard self.menuCardViewRecyclePool[id] == nil else { continue } + // Unhighlight before detaching: the highlight tracker unwinds through the + // outgoing item's `view`, which is about to become nil, so a recycled view + // would otherwise re-attach visibly highlighted with no path to clear it. + if self.highlightedMenuItems[menuKey] === item { + if !preserveHighlightedItem { + self.highlightedMenuItems.removeValue(forKey: menuKey) + } + } + (view as? MenuCardHighlighting)?.setHighlighted(false) + item.view = nil + self.menuCardViewRecyclePool[id] = view + } + } + + /// Pops a pool entry adoptable as `ViewType`: the same card identifier when its view + /// matches, otherwise the first type-compatible leftover. The fallback is what makes + /// provider switches cheap — a different provider's card with a different identifier but + /// the same SwiftUI content type (for example two providers' usage cards) is repainted + /// in place instead of being rebuilt. + func takeRecyclableMenuCardView(for id: String, as type: ViewType.Type) -> ViewType? { + if let candidate = self.menuCardViewRecyclePool.removeValue(forKey: id) { + if let adopted = candidate as? ViewType { + return adopted + } + // A same-id view of an incompatible shape can never be adopted later in this + // pass; dropping it restores the build-fresh behavior. + return nil + } + guard let match = self.menuCardViewRecyclePool.first(where: { $0.value is ViewType }) else { + return nil + } + self.menuCardViewRecyclePool.removeValue(forKey: match.key) + return match.value as? ViewType + } + + func clearMenuCardViewRecyclePool() { + self.menuCardViewRecyclePool.removeAll(keepingCapacity: true) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 527e354390..0b3de36751 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -82,8 +82,9 @@ final class MenuHostingView: NSHostingView { @MainActor final class MenuCardItemHostingView: NSHostingView, MenuCardHighlighting, MenuCardMeasuring { - private let highlightState: MenuCardHighlightState - private let onClick: (() -> Void)? + let highlightState: MenuCardHighlightState + private var onClick: (() -> Void)? + private var hasClickRecognizer = false override var allowsVibrancy: Bool { true @@ -100,12 +101,29 @@ final class MenuCardItemHostingView: NSHostingView, Menu self.onClick = onClick super.init(rootView: rootView) if onClick != nil { - let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:))) - recognizer.buttonMask = 0x1 - self.addGestureRecognizer(recognizer) + self.installClickRecognizer() } } + /// Reuses this hosting view for a rebuilt card with the same identity: the replaced + /// `rootView` is diffed in place by SwiftUI instead of tearing down and recreating the + /// hosting view and its graph. Callers must construct `rootView` around this view's own + /// `highlightState` so menu hover highlighting keeps driving the rendered content. + func prepareForReuse(rootView: Content, onClick: (() -> Void)?) { + self.rootView = rootView + self.onClick = onClick + if onClick != nil, !self.hasClickRecognizer { + self.installClickRecognizer() + } + } + + private func installClickRecognizer() { + let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:))) + recognizer.buttonMask = 0x1 + self.addGestureRecognizer(recognizer) + self.hasClickRecognizer = true + } + required init(rootView: Content) { self.highlightState = MenuCardHighlightState() self.onClick = nil diff --git a/Sources/CodexBar/StatusItemController+MenuReconcile.swift b/Sources/CodexBar/StatusItemController+MenuReconcile.swift new file mode 100644 index 0000000000..c8f0e1f1ad --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuReconcile.swift @@ -0,0 +1,132 @@ +import AppKit + +/// Pre-harvest snapshot of one live content row, captured before card views are detached +/// into the recycle pool so reconciliation can still compare row shapes afterwards. +struct MenuRowShape { + let isSeparator: Bool + let id: String? + let viewClassName: String? +} + +extension StatusItemController { + func menuContentShapes(in menu: NSMenu, fromIndex: Int) -> [MenuRowShape] { + guard fromIndex >= 0, fromIndex <= menu.items.count else { return [] } + return menu.items[fromIndex...].map { item in + MenuRowShape( + isSeparator: item.isSeparatorItem, + id: item.representedObject as? String, + viewClassName: item.view.map { String(describing: type(of: $0)) }) + } + } + + /// Position-wise in-place reconciliation: live rows whose shape matches the freshly + /// built content (separator placement, card identifier, view class) are updated in + /// place — views transplanted, plain rows recopied — and only the mismatched middle + /// span is removed and reinserted. Matching runs from both ends, so the expensive card + /// rows at the top and the shared action rows at the bottom survive even a provider + /// switch whose middle sections differ; AppKit then relayouts the open tracked menu for + /// the few changed rows instead of once per row. + func reconcileMenuContent( + _ menu: NSMenu, + fromIndex: Int, + shapes: [MenuRowShape], + with scratch: NSMenu) + { + defer { self.finishReconciledHighlightTracking(in: menu) } + let newItems = scratch.items + scratch.removeAllItems() + guard menu.items.count - fromIndex == shapes.count else { + // The live region changed underneath the snapshot; replace it wholesale. + self.replaceMenuContent(menu, fromIndex: fromIndex, with: newItems) + return + } + + func updatable(_ shape: MenuRowShape, _ newItem: NSMenuItem) -> Bool { + guard shape.isSeparator == newItem.isSeparatorItem else { return false } + if shape.isSeparator { return true } + guard shape.id == newItem.representedObject as? String else { return false } + return shape.viewClassName == newItem.view.map { String(describing: type(of: $0)) } + } + + var prefix = 0 + while prefix < min(shapes.count, newItems.count), updatable(shapes[prefix], newItems[prefix]) { + prefix += 1 + } + var suffix = 0 + while suffix < min(shapes.count, newItems.count) - prefix, + updatable(shapes[shapes.count - 1 - suffix], newItems[newItems.count - 1 - suffix]) + { + suffix += 1 + } + + for offset in 0.. fromIndex { + menu.removeItem(at: fromIndex) + } + for item in newItems { + menu.addItem(item) + } + } + + private func updateMenuItemInPlace(_ liveItem: NSMenuItem, from newItem: NSMenuItem) { + if liveItem.isSeparatorItem { return } + let remainsHighlighted = liveItem.menu.map { + self.highlightedMenuItems[ObjectIdentifier($0)] === liveItem + } ?? false + // Detach from the scratch item first so a view or submenu is never referenced by + // two menu items at once. + let view = newItem.view + newItem.view = nil + let submenu = newItem.submenu + newItem.submenu = nil + liveItem.view = view + (view as? MenuCardHighlighting)?.setHighlighted(remainsHighlighted) + liveItem.submenu = submenu + liveItem.title = newItem.title + liveItem.attributedTitle = newItem.attributedTitle + liveItem.action = newItem.action + liveItem.target = newItem.target + liveItem.representedObject = newItem.representedObject + liveItem.state = newItem.state + liveItem.isEnabled = newItem.isEnabled + liveItem.image = newItem.image + liveItem.toolTip = newItem.toolTip + liveItem.keyEquivalent = newItem.keyEquivalent + liveItem.keyEquivalentModifierMask = newItem.keyEquivalentModifierMask + liveItem.indentationLevel = newItem.indentationLevel + if #available(macOS 14.4, *) { + liveItem.subtitle = newItem.subtitle + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift new file mode 100644 index 0000000000..c55794f4b1 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift @@ -0,0 +1,137 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. + struct MenuUpdateContext { + let provider: UsageProvider? + let currentProvider: UsageProvider + let switcherSelection: ProviderSwitcherSelection + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let openAIContext: OpenAIWebContext + let descriptor: MenuDescriptor + } + + /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. + func updateMenuContentPreservingSwitcher( + _ menu: NSMenu, + context: MenuUpdateContext) + { + self.performMenuMutationWithoutAnimation { + let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) + if let switcherView = menu.items.first?.view as? ProviderSwitcherView { + switcherView.updateSelection(context.switcherSelection) + switcherView.updateQuotaIndicators() + } + let outgoingSelection = self.lastMergedMenuContentSelection + let isSelectionSwitch = outgoingSelection != nil && outgoingSelection != context.switcherSelection + let enabledProviders = self.store.enabledProvidersForDisplay() + + if isSelectionSwitch, + let outgoingSelection, + self.hasReusableMergedSwitcherContent( + for: context.switcherSelection, + in: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + // Instant path: the incoming tab reattaches wholesale, so park the outgoing + // items for an equally instant switch-back. + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: outgoingSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth) + while menu.items.count > contentStartIndex { + menu.removeItem(at: contentStartIndex) + } + self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + if self.addCachedMergedSwitcherContent( + for: context.switcherSelection, + to: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + return + } + self.addSwitcherScopedMenuContent(into: menu, captureMenu: menu, context: context) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: context.switcherSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth, + contentVersion: self.menuContentVersion) + return + } + + // Rebuild path (data tick, or switch whose incoming tab must be built): recycle + // the outgoing hosting views and reconcile in place when the row skeleton is + // unchanged, so an open tracked menu sees content mutations instead of item + // churn. The fresh content is built into a detached scratch menu while its + // interaction closures capture the live menu they will serve. + let shapes = self.menuContentShapes(in: menu, fromIndex: contentStartIndex) + self.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: contentStartIndex, + displacedSelection: outgoingSelection, + preserveHighlightedItem: true) + defer { self.clearMenuCardViewRecyclePool() } + self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + let scratch = NSMenu() + scratch.autoenablesItems = false + self.addSwitcherScopedMenuContent(into: scratch, captureMenu: menu, context: context) + self.reconcileMenuContent(menu, fromIndex: contentStartIndex, shapes: shapes, with: scratch) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: context.switcherSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth, + contentVersion: self.menuContentVersion) + } + } + + /// Adds everything below the provider switcher (account switchers, card content, and + /// actionable sections) to `target`, which may be a detached scratch menu; interaction + /// closures always capture `captureMenu`, the live menu the rows will serve. + private func addSwitcherScopedMenuContent( + into target: NSMenu, + captureMenu: NSMenu, + context: MenuUpdateContext) + { + self.addCodexAccountSwitcherIfNeeded( + to: target, + display: context.codexAccountDisplay, + width: context.menuWidth, + captureMenu: captureMenu) + self.lastCodexAccountMenuDisplay = context.codexAccountDisplay + self.addTokenAccountSwitcherIfNeeded( + to: target, + display: context.tokenAccountDisplay, + width: context.menuWidth, + captureMenu: captureMenu) + self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay + + let menuContext = MenuCardContext( + currentProvider: context.currentProvider, + selectedProvider: context.provider, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay, + openAIContext: context.openAIContext) + self.addPrimaryMenuContent( + to: target, + context: menuContext, + switcherSelection: context.switcherSelection, + captureMenu: captureMenu) + self.addActionableSections( + context.descriptor.sections, + to: target, + width: context.menuWidth, + captureMenu: captureMenu) + } +} diff --git a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift index b31c447c92..5f323f1081 100644 --- a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift +++ b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift @@ -65,6 +65,32 @@ extension StatusItemController { self.mergedSwitcherContentCaches[ObjectIdentifier(menu), default: [:]][selection] = entry } + /// Non-consuming variant of `addCachedMergedSwitcherContent`'s lookup: reports whether a + /// reusable entry exists (evicting it when stale) without attaching anything, so callers + /// can choose between reattaching cached content and recycling the outgoing views. + func hasReusableMergedSwitcherContent( + for selection: ProviderSwitcherSelection, + in menu: NSMenu, + menuWidth: CGFloat, + codexAccountDisplay: CodexAccountMenuDisplay?, + tokenAccountDisplay: TokenAccountMenuDisplay?) + -> Bool + { + let key = ObjectIdentifier(menu) + guard let entry = self.mergedSwitcherContentCaches[key]?[selection] else { return false } + guard entry.matches( + requiredMenuContentVersion: self.latestRequiredMenuRebuildVersion, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + localizationSignature: self.menuLocalizationSignature()) + else { + self.mergedSwitcherContentCaches[key]?.removeValue(forKey: selection) + return false + } + return true + } + func addCachedMergedSwitcherContent( for selection: ProviderSwitcherSelection, to menu: NSMenu, diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 7d28152d6d..23208fcf55 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -235,6 +235,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var mergedSwitcherContentCaches: [ObjectIdentifier: [ProviderSwitcherSelection: CachedMergedSwitcherMenuContent]] = [:] var preservesMergedSwitcherContentCachesDuringInvalidation = false + /// Card hosting views harvested from items about to be discarded by the current populate + /// pass, keyed by card identifier; consumed by `makeMenuCardItem` and cleared when the + /// pass finishes. Never outlives a single synchronous menu population. + var menuCardViewRecyclePool: [String: NSView] = [:] /// Monotonic token used to ignore stale deferred provider-switcher menu rebuilds. var providerSwitcherUpdateToken = 0 var providerSelectionUIRefreshTask: Task? diff --git a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift new file mode 100644 index 0000000000..7c623bb6ed --- /dev/null +++ b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift @@ -0,0 +1,449 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +private final class RecordingMenuHighlightView: NSView, MenuCardHighlighting { + private(set) var isHighlighted = false + + func setHighlighted(_ highlighted: Bool) { + self.isHighlighted = highlighted + } +} + +extension StatusMenuTests { + private func makeRecyclingController(settings: SettingsStore) -> StatusItemController { + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + return StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + } + + private func cardViewIdentities(in menu: NSMenu) -> [String: ObjectIdentifier] { + var identities: [String: ObjectIdentifier] = [:] + for item in menu.items { + guard let id = item.representedObject as? String else { continue } + guard let view = item.view, view is any MenuCardMeasuring else { continue } + identities[id] = ObjectIdentifier(view) + } + return identities + } + + @Test + func `data only repopulate reuses menu card hosting views`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstPass = self.cardViewIdentities(in: menu) + #expect(!firstPass.isEmpty) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.populateMenu(menu, provider: .codex) + let secondPass = self.cardViewIdentities(in: menu) + + #expect(secondPass.keys.sorted() == firstPass.keys.sorted()) + for (id, identity) in firstPass { + #expect(secondPass[id] == identity, "card \(id) should reuse its hosting view") + } + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `merged data tick reconciles items in place without churn`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = false + let registry = ProviderRegistry.shared + let enabled: Set = [.codex, .claude] + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabled.contains(provider)) + } + } + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.selectedMenuProvider = .codex + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let itemsBefore = menu.items.map(ObjectIdentifier.init) + let cardViewsBefore = self.cardViewIdentities(in: menu) + #expect(!cardViewsBefore.isEmpty) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.populateMenu(menu, provider: .codex) + + let itemsAfter = menu.items.map(ObjectIdentifier.init) + #expect(itemsAfter == itemsBefore, "data-only repopulate should not remove or insert menu items") + let cardViewsAfter = self.cardViewIdentities(in: menu) + for (id, identity) in cardViewsBefore { + #expect(cardViewsAfter[id] == identity, "card \(id) should reuse its hosting view") + } + } + + @Test + func `reconcile keeps matching edge rows when the middle differs`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + func plainItem(_ title: String) -> NSMenuItem { + NSMenuItem(title: title, action: nil, keyEquivalent: "") + } + + let menu = NSMenu() + menu.addItem(controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300)) + menu.addItem(.separator()) + menu.addItem(plainItem("Old Provider Action")) + menu.addItem(plainItem("Old Provider Detail")) + menu.addItem(.separator()) + menu.addItem(plainItem("Settings")) + let cardItem = menu.items[0] + let cardView = cardItem.view + let settingsItem = menu.items[5] + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("other provider card"), id: "menuCard", width: 300)) + scratch.addItem(.separator()) + scratch.addItem(plainItem("New Provider Action")) + scratch.addItem(.separator()) + scratch.addItem(plainItem("Settings")) + + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items.count == 5) + #expect(menu.items[0] === cardItem, "card row should be updated in place") + #expect(menu.items[0].view === cardView, "card hosting view should be recycled in place") + #expect(menu.items[4] === settingsItem, "shared trailing row should be updated in place") + #expect(menu.items[2].title == "New Provider Action") + } + + @Test + func `reconcile preserves highlight on a retained custom action row`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = NSMenuItem() + liveItem.isEnabled = true + liveItem.representedObject = "action" + liveItem.view = RecordingMenuHighlightView() + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + + let replacementView = RecordingMenuHighlightView() + let replacementItem = NSMenuItem() + replacementItem.isEnabled = true + replacementItem.representedObject = "action" + replacementItem.view = replacementView + let scratch = NSMenu() + scratch.addItem(replacementItem) + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(liveItem.view === replacementView) + #expect(replacementView.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + } + + @Test + func `reconcile restores highlight on a retained recycled card`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300) + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + guard let hosting = liveItem.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: nil, + preserveHighlightedItem: true) + defer { controller.clearMenuCardViewRecyclePool() } + #expect(!hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300)) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(liveItem.view === hosting) + #expect(hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + } + + @Test + func `harvesting consumes only the displaced selection cache entry`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300) + menu.addItem(item) + + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: .provider(.codex)) + defer { controller.clearMenuCardViewRecyclePool() } + + #expect(controller.menuCardViewRecyclePool.count == 1) + #expect(item.view == nil) + let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] + #expect(remaining?[.provider(.codex)] == nil) + #expect(remaining?[.overview] != nil) + } + + @Test + func `harvesting consumes displaced cache when card rendering is disabled`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: .provider(.codex)) + + let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] + #expect(remaining?[.provider(.codex)] == nil) + #expect(remaining?[.overview] != nil) + } + + @Test + func `type compatible leftover is adopted across card identifiers`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("codex usage"), id: "menuCard-0", width: 300) + menu.addItem(original) + let originalView = original.view + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let switched = controller.makeMenuCardItem(Text("claude usage"), id: "menuCard", width: 300) + + #expect(switched.view === originalView) + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `recycled card keeps its hosting view and highlight state`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300) + menu.addItem(original) + guard let originalView = original.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let rebuilt = controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300) + + #expect(rebuilt.view === originalView) + guard let rebuiltView = rebuilt.view as? MenuCardItemHostingView> + else { + Issue.record("expected the recycled hosting view") + return + } + #expect(rebuiltView.highlightState === originalView.highlightState) + rebuiltView.setHighlighted(true) + #expect(rebuiltView.highlightState.isHighlighted) + rebuiltView.setHighlighted(false) + } + + @Test + func `harvesting a highlighted card clears its highlight and tracking entry`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300) + menu.addItem(item) + controller.menu(menu, willHighlight: item) + guard let hosting = item.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + #expect(hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === item) + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + + #expect(!hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil) + + let rebuilt = controller.makeMenuCardItem(Text("rebuilt"), id: "menuCard", width: 300) + #expect(rebuilt.view === hosting) + #expect(!hosting.highlightState.isHighlighted) + } + + @Test + func `same id with different content type builds a fresh view`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("text card"), id: "menuCard", width: 300) + menu.addItem(original) + let originalView = original.view + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let rebuilt = controller.makeMenuCardItem(Image(systemName: "clock"), id: "menuCard", width: 300) + + #expect(rebuilt.view != nil) + #expect(rebuilt.view !== originalView) + // The incompatible pool entry is consumed rather than left behind. + #expect(controller.menuCardViewRecyclePool.isEmpty) + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index a49fc7c1c7..c10999cb14 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -156,7 +156,7 @@ struct StatusMenuSwitcherRefreshTests { } @Test - func `merged provider switch restores cached tab content`() async throws { + func `merged provider switch updates live tab rows in place`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(true) @@ -198,12 +198,14 @@ struct StatusMenuSwitcherRefreshTests { } defer { controller._test_openMenuRebuildObserver = nil } + // Provider switches now reconcile matching rows in place instead of parking and + // restoring distinct item sets per tab: the same NSMenuItem objects carry each + // tab's freshly built content, so AppKit never relayouts the open menu per insert. let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) #expect(menu.items.indices.contains(contentStartIndex)) - let alternateContentID = ObjectIdentifier(menu.items[contentStartIndex]) - #expect(alternateContentID != originalContentID) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) @@ -215,7 +217,7 @@ struct StatusMenuSwitcherRefreshTests { #expect(restoredSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) await Self.waitForRebuildCount(3, rebuildCount: { rebuildCount }) #expect(menu.items.indices.contains(contentStartIndex)) - #expect(ObjectIdentifier(menu.items[contentStartIndex]) == alternateContentID) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) controller.invalidateMenus() #expect(controller.mergedSwitcherContentCaches.isEmpty) @@ -253,9 +255,7 @@ struct StatusMenuSwitcherRefreshTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) - let originalContent = try #require( - menu.items.indices.contains(contentStartIndex) ? menu.items[contentStartIndex] : nil) - let originalContentID = ObjectIdentifier(originalContent) + #expect(menu.items.indices.contains(contentStartIndex)) let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) @@ -266,6 +266,7 @@ struct StatusMenuSwitcherRefreshTests { defer { controller._test_openMenuRebuildObserver = nil } controller.invalidateMenus() + #expect(controller.mergedSwitcherContentCaches.isEmpty) let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) @@ -274,8 +275,17 @@ struct StatusMenuSwitcherRefreshTests { #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + // Rows are reconciled in place, so freshness is guaranteed by rebuilding content + // from current data rather than by minting new items: the live menu must be marked + // fresh and no cached entry may predate the required invalidation. (In-place item + // identity itself is covered deterministically in MenuCardViewRecyclingTests; here + // async gate state may legitimately route a populate through the full rebuild.) #expect(menu.items.indices.contains(contentStartIndex)) - #expect(ObjectIdentifier(menu.items[contentStartIndex]) != originalContentID) + let menuKey = ObjectIdentifier(menu) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + for entry in controller.mergedSwitcherContentCaches[menuKey]?.values ?? [:].values { + #expect(entry.requiredMenuContentVersion >= controller.latestRequiredMenuRebuildVersion) + } } @Test