diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0fc9aac9..c2ca2efdb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed - Menu bar: anchor merged provider dropdowns to the status item's trailing edge without marking preserved in-flight refresh content fresh, preventing horizontal drift while keeping deferred updates visible (#1288). Thanks @Yuxin-Qiao! +- Menu bar: avoid republishing unchanged provider storage footprints so background scans no longer trigger unnecessary menu observation work (#1416). Thanks @soohanpark! - Cost usage: replace repeated Foundation metadata/root checks with one portable file-stat pass so expired Codex history refreshes stay responsive on very large session archives (#1392). Thanks @TheAngryPit and @ProspectOre! - Cursor: show the Safari Full Disk Access recovery hint before the long browser login list so permission guidance remains visible when menu errors truncate (#1419, fixes #1417). Thanks @hhh2210! - Cursor: present legacy request-based plans as one Requests quota with the raw used/limit count instead of unrelated token-based Auto/API bars (#1420, fixes #1418). Thanks @hhh2210! diff --git a/Sources/CodexBar/UsageStore+ProviderStorage.swift b/Sources/CodexBar/UsageStore+ProviderStorage.swift index a31f01740b..826ab19907 100644 --- a/Sources/CodexBar/UsageStore+ProviderStorage.swift +++ b/Sources/CodexBar/UsageStore+ProviderStorage.swift @@ -177,9 +177,25 @@ extension UsageStore { updatedAt: Date) { let providerSet = Set(providers) - self.providerStorageFootprints = self.providerStorageFootprints.filter { !providerSet.contains($0.key) } + var updated = self.providerStorageFootprints.filter { !providerSet.contains($0.key) } for provider in providers { - self.providerStorageFootprints[provider] = footprints[provider] + // Reuse the existing footprint when only its scan timestamp would change, so the equality + // guard below treats an unchanged scan as a no-op. + if let incoming = footprints[provider], + let existing = self.providerStorageFootprints[provider], + existing.hasSameContents(as: incoming) + { + updated[provider] = existing + } else { + updated[provider] = footprints[provider] + } + } + // Only republish the observable footprints when a value actually changed. Storage scans run + // on every menu open and roughly every 5 minutes; an unconditional re-assignment wakes + // `menuObservationToken` -> `invalidateMenus` churn (clearing menu caches) even when the + // scanned bytes are identical. + if updated != self.providerStorageFootprints { + self.providerStorageFootprints = updated } self.lastStorageRefreshSignature = signature self.lastStorageRefreshRequestKey = requestKey ?? signature diff --git a/Sources/CodexBarCore/ProviderStorageFootprint.swift b/Sources/CodexBarCore/ProviderStorageFootprint.swift index ea5e62dd70..e173ff4916 100644 --- a/Sources/CodexBarCore/ProviderStorageFootprint.swift +++ b/Sources/CodexBarCore/ProviderStorageFootprint.swift @@ -50,6 +50,18 @@ public struct ProviderStorageFootprint: Sendable, Equatable { self.totalBytes > 0 } + /// Value equality that ignores `updatedAt`. Two scans of identical on-disk data differ only by + /// their scan timestamp, so callers use this to avoid re-publishing observable state (and the + /// menu-invalidation churn that follows) when nothing the user sees has actually changed. + public func hasSameContents(as other: ProviderStorageFootprint) -> Bool { + self.provider == other.provider && + self.totalBytes == other.totalBytes && + self.paths == other.paths && + self.missingPaths == other.missingPaths && + self.unreadablePaths == other.unreadablePaths && + self.components == other.components + } + public var cleanupRecommendations: [ProviderStorageRecommendation] { ProviderStorageRecommendation.recommendations(for: self) } diff --git a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift index 2b1a82010c..aff4a7fb82 100644 --- a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift +++ b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift @@ -1,10 +1,28 @@ import AppKit import CodexBarCore import Foundation +import Observation import Testing @testable import CodexBar struct ProviderStorageFootprintTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + @Test func `scanner sums nested regular files and skips symlink targets`() throws { let root = try Self.makeTemporaryDirectory() @@ -312,6 +330,54 @@ struct ProviderStorageFootprintTests { #expect(store.storageFootprintText(for: .codex) == "No local data found") } + @Test + @MainActor + func `repeated identical storage refresh does not republish observable footprints`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + let sessions = codexHome.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + try Data(repeating: 1, count: 32).write(to: sessions.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-identity-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + settings.providerStorageFootprintsEnabled = true + store.managedCodexAccountsForStorageOverride = [] + + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + + // A second scan over identical on-disk data must not re-assign the observable property. + // Storage scans run on every menu open and every ~5 min; an unconditional re-publish wakes + // `menuObservationToken` -> `invalidateMenus` churn for no value change. + let didRepublish = ObservationFlag() + withObservationTracking { + _ = store.providerStorageFootprints + } onChange: { + didRepublish.set() + } + await store.refreshStorageFootprintsForOverviewNow() + + #expect(didRepublish.get() == false) + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + } + @Test @MainActor func `storage refresh is opt in and clears stale footprints when disabled`() async throws {