From 532a45f7329674faa37f27a29a0b46c23d89529a Mon Sep 17 00:00:00 2001 From: Elijah Friedman Date: Thu, 18 Jun 2026 20:08:15 -0400 Subject: [PATCH 1/9] feat: segmented storage breakdown bar with expandable Other --- .../Resources/en.lproj/Localizable.strings | 3 + .../CodexBar/StorageBreakdownMenuView.swift | 193 ++++++++++++++---- Sources/CodexBarCore/UsageFormatter.swift | 21 ++ 3 files changed, 178 insertions(+), 39 deletions(-) diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index a77d4ea033..dc248e7ec1 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -879,6 +879,9 @@ "Clearing removes local temporary provider data." = "Clearing removes local temporary provider data."; "Total: %@" = "Total: %@"; "%d more items" = "%d more items"; +"Other (%d items)" = "Other (%d items)"; +"Expand" = "Expand"; +"Collapse" = "Collapse"; "Cleanup ideas" = "Cleanup ideas"; "%d unreadable item(s) skipped" = "%d unreadable item(s) skipped"; diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index 4e20eb66d1..aadd018a8f 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -9,12 +9,13 @@ struct StorageMenuCardSectionView: View { let width: CGFloat var body: some View { - VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { Text(L("Storage")) .font(.body) - .fontWeight(.medium) Text(self.storageText) - .font(.caption) + .font(.body) + .foregroundStyle(.secondary) + Spacer(minLength: 0) } .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) @@ -28,27 +29,93 @@ struct StorageBreakdownMenuView: View { let width: CGFloat let maxHeight: CGFloat + @State private var otherExpanded = false + init(footprint: ProviderStorageFootprint, width: CGFloat, maxHeight: CGFloat = 560) { self.footprint = footprint self.width = width self.maxHeight = maxHeight } + /// One entry in the segmented bar and its matching legend row. Overflow components past the row + /// budget collapse into a single trailing "Other" segment with no copyable path of its own. + private struct Segment: Identifiable { + let id: String + let name: String + let bytes: Int64 + let color: Color + let path: String? + } + + /// How many legend rows we let the breakdown show before collapsing the tail into "Other". + private static let maxRows = 8 + + private static let segmentPalette: [Color] = [ + Color(red: 0.20, green: 0.51, blue: 0.96), + Color(red: 0.96, green: 0.55, blue: 0.20), + Color(red: 0.30, green: 0.78, blue: 0.47), + Color(red: 0.66, green: 0.42, blue: 0.93), + Color(red: 0.95, green: 0.74, blue: 0.22), + Color(red: 0.92, green: 0.36, blue: 0.55), + Color(red: 0.27, green: 0.76, blue: 0.82), + ] + + private static let otherColor = Color(nsColor: .tertiaryLabelColor) + var cleanupRecommendations: [ProviderStorageRecommendation] { self.footprint.cleanupRecommendations } var copyablePaths: [String] { let recommendationPaths = self.cleanupRecommendations.map(\.path) - return self.visibleComponents.map(\.path) + recommendationPaths + return self.segments.compactMap(\.path) + recommendationPaths } - private var visibleComponents: [ProviderStorageFootprint.Component] { - Array(self.footprint.components.prefix(8)) + /// Visible components mapped to colored segments, with any tail beyond `maxRows` folded into a + /// single "Other" entry so the bar and legend never exceed the row budget. + private var segments: [Segment] { + let components = self.footprint.components + guard !components.isEmpty else { return [] } + + func color(_ index: Int) -> Color { + Self.segmentPalette[index % Self.segmentPalette.count] + } + + func segment(_ component: ProviderStorageFootprint.Component, _ index: Int) -> Segment { + Segment( + id: component.id, + name: component.name, + bytes: component.totalBytes, + color: color(index), + path: component.path) + } + + if components.count <= Self.maxRows { + return components.enumerated().map { segment($1, $0) } + } + + let visible = components.prefix(Self.maxRows - 1) + let overflow = components.dropFirst(Self.maxRows - 1) + let otherBytes = overflow.reduce(Int64(0)) { $0 + $1.totalBytes } + return visible.enumerated().map { segment($1, $0) } + [ + Segment( + id: "__other__", + name: String(format: L("Other (%d items)"), overflow.count), + bytes: otherBytes, + color: Self.otherColor, + path: nil), + ] + } + + private var segmentTotalBytes: Int64 { + max(self.segments.reduce(Int64(0)) { $0 + $1.bytes }, 1) } - private var maxBytes: Int64 { - max(self.visibleComponents.map(\.totalBytes).max() ?? 0, 1) + /// The components folded into the trailing "Other" segment, revealed when it is expanded. + private var overflowComponents: [ProviderStorageFootprint.Component] { + let components = self.footprint.components + guard components.count > Self.maxRows else { return [] } + return Array(components.dropFirst(Self.maxRows - 1)) } var body: some View { @@ -70,28 +137,24 @@ struct StorageBreakdownMenuView: View { Text(L("Storage")) .font(.body) .fontWeight(.medium) - Text(String(format: L("Total: %@"), UsageFormatter.byteCountString(self.footprint.totalBytes))) + Text(String(format: L("Total: %@"), UsageFormatter.byteCountStringLong(self.footprint.totalBytes))) .font(.caption) .foregroundStyle(.secondary) } - if self.visibleComponents.isEmpty { + if self.segments.isEmpty { Text(L("No local data found")) .font(.footnote) .foregroundStyle(.secondary) } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(self.visibleComponents) { component in - self.componentRow(component) + self.segmentedBar + VStack(alignment: .leading, spacing: 6) { + ForEach(self.segments) { segment in + self.legendRow(segment) } } } - if self.footprint.components.count > self.visibleComponents.count { - Text(String(format: L("%d more items"), self.footprint.components.count - self.visibleComponents.count)) - .font(.caption) - .foregroundStyle(.secondary) - } if !self.cleanupRecommendations.isEmpty { Divider() .padding(.vertical, 2) @@ -115,34 +178,91 @@ struct StorageBreakdownMenuView: View { .frame(width: self.width, alignment: .leading) } - private func componentRow(_ component: ProviderStorageFootprint.Component) -> some View { - let fraction = CGFloat(max(0, min(1, Double(component.totalBytes) / Double(self.maxBytes)))) - return VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline) { - Text(component.path) + private var segmentedBar: some View { + GeometryReader { proxy in + let total = CGFloat(self.segmentTotalBytes) + ZStack(alignment: .leading) { + Capsule() + .fill(Color(nsColor: .quaternaryLabelColor)) + HStack(spacing: 0) { + ForEach(self.segments) { segment in + Rectangle() + .fill(segment.color) + .frame(width: max(2, proxy.size.width * CGFloat(segment.bytes) / total)) + } + } + } + .clipShape(Capsule()) + } + .frame(height: 5) + } + + private func legendRow(_ segment: Segment) -> some View { + let isOther = segment.path == nil + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle() + .fill(segment.color) + .frame(width: 9, height: 9) + Text(segment.name) .font(.caption) + .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.middle) - .help(component.path) + .help(segment.path ?? segment.name) .layoutPriority(1) Spacer() - StoragePathCopyButton(path: component.path) - Text(UsageFormatter.byteCountString(component.totalBytes)) + if let path = segment.path { + StoragePathCopyButton(path: path) + } else { + self.otherExpandButton + } + Text(UsageFormatter.byteCountString(segment.bytes)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule() - .fill(Color(nsColor: .quaternaryLabelColor)) - Capsule() - .fill(self.providerColor) - .frame(width: max(2, proxy.size.width * fraction)) + if isOther, self.otherExpanded { + self.overflowList + } + } + } + + private var otherExpandButton: some View { + Button { + self.otherExpanded.toggle() + } label: { + Image(systemName: self.otherExpanded ? "chevron.down" : "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(self.otherExpanded ? L("Collapse") : L("Expand")) + .accessibilityLabel(self.otherExpanded ? L("Collapse") : L("Expand")) + } + + /// Plain name + size rows for the items folded into "Other" — no colors, indented under its name. + private var overflowList: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(self.overflowComponents) { component in + HStack(spacing: 8) { + Text(component.name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(component.path) + Spacer() + Text(UsageFormatter.byteCountString(component.totalBytes)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } } - .frame(height: 5) } + .padding(.leading, 17) } private func recommendationRow(_ recommendation: ProviderStorageRecommendation) -> some View { @@ -176,11 +296,6 @@ struct StorageBreakdownMenuView: View { .fixedSize(horizontal: false, vertical: true) } } - - private var providerColor: Color { - let color = ProviderDescriptorRegistry.descriptor(for: self.footprint.provider).branding.color - return Color(red: color.red, green: color.green, blue: color.blue) - } } struct StoragePathCopyButton: View { diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 09e2fcfd92..88ab0dc88e 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -263,6 +263,27 @@ public enum UsageFormatter { return "\(bytes) B" } + /// Same magnitudes as `byteCountString`, but spelled out ("megabytes" instead of "MB"). + public static func byteCountStringLong(_ bytes: Int64) -> String { + let sign = bytes < 0 ? "-" : "" + let absBytes = Double(Swift.abs(bytes)) + let units: [(threshold: Double, divisor: Double, singular: String, plural: String)] = [ + (1024 * 1024 * 1024, 1024 * 1024 * 1024, "gigabyte", "gigabytes"), + (1024 * 1024, 1024 * 1024, "megabyte", "megabytes"), + (1024, 1024, "kilobyte", "kilobytes"), + ] + + for unit in units where absBytes >= unit.threshold { + let scaled = absBytes / unit.divisor + let format = scaled >= 10 || scaled.rounded(.towardZero) == scaled ? "%.0f" : "%.1f" + let formatted = String(format: format, scaled) + let word = formatted == "1" ? unit.singular : unit.plural + return "\(sign)\(formatted) \(word)" + } + + return "\(bytes) \(Swift.abs(bytes) == 1 ? "byte" : "bytes")" + } + public static func creditEventSummary(_ event: CreditEvent) -> String { let formatter = DateFormatter() formatter.dateStyle = .medium From e5048daecb8b5280089275e9b7b4ad1b50d37f6d Mon Sep 17 00:00:00 2001 From: Elijah Friedman Date: Thu, 18 Jun 2026 20:31:02 -0400 Subject: [PATCH 2/9] fix: prevent storage bar segments from clipping legend entries --- .../Resources/ar.lproj/Localizable.strings | 3 +++ .../Resources/fa.lproj/Localizable.strings | 3 +++ .../Resources/th.lproj/Localizable.strings | 3 +++ Sources/CodexBar/StorageBreakdownMenuView.swift | 16 ++++++++++++++-- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 50e4da8e0d..c7b6c897af 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -879,6 +879,9 @@ "Clearing removes local temporary provider data." = "المسح يزيل بيانات مقدم الخدمة المؤقت المحلي."; "Total: %@" = "المجموع: %@"; "%d more items" = "%d المزيد من العناصر"; +"Other (%d items)" = "أخرى (%d عناصر)"; +"Expand" = "توسيع"; +"Collapse" = "طيّ"; "Cleanup ideas" = "أفكار التنظيف"; "%d unreadable item(s) skipped" = "%d العناصر غير القابلة للقراءة تم تخطيها"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 9989373f59..6e2eaff3a0 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -879,6 +879,9 @@ "Clearing removes local temporary provider data." = "پاک سازی داده های ارائه دهنده موقت محلی را حذف می کند."; "Total: %@" = "کل: %@"; "%d more items" = "%d آیتم های بیشتر"; +"Other (%d items)" = "موارد دیگر (%d مورد)"; +"Expand" = "گسترش"; +"Collapse" = "جمع کردن"; "Cleanup ideas" = "ایده های پاکسازی"; "%d unreadable item(s) skipped" = "%d آیتم(های) غیرقابل خواندن رد شده اند"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 0f31521014..4cf46bfc60 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -879,6 +879,9 @@ "Clearing removes local temporary provider data." = "การหักล้างจะลบข้อมูลผู้ให้บริการชั่วคราวในเครื่อง"; "Total: %@" = "ทั้งหมด: %@"; "%d more items" = "%d รายการเพิ่มเติม"; +"Other (%d items)" = "อื่น ๆ (%d รายการ)"; +"Expand" = "ขยาย"; +"Collapse" = "ยุบ"; "Cleanup ideas" = "ไอเดียการล้างข้อมูล"; "%d unreadable item(s) skipped" = "ข้ามรายการที่อ่านไม่ได้ %d รายการ"; diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index aadd018a8f..2f2f0d732a 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -180,7 +180,6 @@ struct StorageBreakdownMenuView: View { private var segmentedBar: some View { GeometryReader { proxy in - let total = CGFloat(self.segmentTotalBytes) ZStack(alignment: .leading) { Capsule() .fill(Color(nsColor: .quaternaryLabelColor)) @@ -188,7 +187,7 @@ struct StorageBreakdownMenuView: View { ForEach(self.segments) { segment in Rectangle() .fill(segment.color) - .frame(width: max(2, proxy.size.width * CGFloat(segment.bytes) / total)) + .frame(width: self.segmentWidth(segment, barWidth: proxy.size.width)) } } } @@ -197,6 +196,19 @@ struct StorageBreakdownMenuView: View { .frame(height: 5) } + /// Each segment gets at least `minWidth` so tiny components stay visible, with the remaining width + /// shared by byte proportion. Reserving the minimums (rather than flooring each width with `max`) + /// keeps the segments summing to exactly `barWidth`, so none get clipped off the capsule's end. + private func segmentWidth(_ segment: Segment, barWidth: CGFloat) -> CGFloat { + let minWidth: CGFloat = 2 + let count = CGFloat(self.segments.count) + let reserved = minWidth * count + guard barWidth > reserved else { return barWidth / max(count, 1) } + let remainder = barWidth - reserved + let proportion = CGFloat(segment.bytes) / CGFloat(self.segmentTotalBytes) + return minWidth + remainder * proportion + } + private func legendRow(_ segment: Segment) -> some View { let isOther = segment.path == nil return VStack(alignment: .leading, spacing: 6) { From 893b96412513421ef4fb83f472ffdd8b29e54f7b Mon Sep 17 00:00:00 2001 From: Elijah Friedman Date: Thu, 18 Jun 2026 20:55:37 -0400 Subject: [PATCH 3/9] update to pass CI --- Sources/CodexBar/Resources/id.lproj/Localizable.strings | 3 +++ Sources/CodexBar/Resources/it.lproj/Localizable.strings | 3 +++ Sources/CodexBar/Resources/pl.lproj/Localizable.strings | 3 +++ Sources/CodexBar/Resources/tr.lproj/Localizable.strings | 3 +++ 4 files changed, 12 insertions(+) diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index fcbca829a4..b5b7053b6d 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -881,6 +881,9 @@ "Clearing removes local temporary provider data." = "Menghapus data sementara penyedia lokal."; "Total: %@" = "Total: %@"; "%d more items" = "%d item lagi"; +"Other (%d items)" = "Lainnya (%d item)"; +"Expand" = "Perluas"; +"Collapse" = "Ciutkan"; "Cleanup ideas" = "Ide pembersihan"; "%d unreadable item(s) skipped" = "%d item tidak terbaca dilewati"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 5874929a33..192c923592 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -881,6 +881,9 @@ "Clearing removes local temporary provider data." = "La cancellazione rimuove i dati temporanei locali dei provider."; "Total: %@" = "Totale: %@"; "%d more items" = "%d elementi in più"; +"Other (%d items)" = "Altro (%d elementi)"; +"Expand" = "Espandi"; +"Collapse" = "Comprimi"; "Cleanup ideas" = "Idee di pulizia"; "%d unreadable item(s) skipped" = "Saltati %d elemento/i illeggibili"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 7e6d1dd884..a172bbdf56 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -881,6 +881,9 @@ "Clearing removes local temporary provider data." = "Czyszczenie usuwa lokalne tymczasowe dane dostawców."; "Total: %@" = "Łącznie: %@"; "%d more items" = "Jeszcze %d pozycji"; +"Other (%d items)" = "Inne (%d elementów)"; +"Expand" = "Rozwiń"; +"Collapse" = "Zwiń"; "Cleanup ideas" = "Pomysły na czyszczenie"; "%d unreadable item(s) skipped" = "Pominięto %d nieczytelnych elementów"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 145564c642..d019df947a 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -877,6 +877,9 @@ "Clearing removes local temporary provider data." = "Temizleme, yerel geçici sağlayıcı verilerini kaldırır."; "Total: %@" = "Toplam: %@"; "%d more items" = "%d öğe daha"; +"Other (%d items)" = "Diğer (%d öğe)"; +"Expand" = "Genişlet"; +"Collapse" = "Daralt"; "Cleanup ideas" = "Temizlik önerileri"; "%d unreadable item(s) skipped" = "%d okunamaz öğe atlandı"; "API key limit" = "API anahtarı limiti"; From 01d2835f060122fadd1fff749abfd455f2c182c3 Mon Sep 17 00:00:00 2001 From: Elijah Friedman Date: Thu, 18 Jun 2026 21:29:45 -0400 Subject: [PATCH 4/9] feat: added replace animation for copying --- Sources/CodexBar/StorageBreakdownMenuView.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index 2f2f0d732a..0489057376 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -320,16 +320,21 @@ struct StoragePathCopyButton: View { Button { self.resetTask?.cancel() MenuPasteboardCopy.perform(self.path, completion: { - self.didCopy = true + withAnimation(.smooth(duration: 0.25)) { + self.didCopy = true + } self.resetTask = Task { @MainActor in try? await Task.sleep(for: .seconds(0.9)) - self.didCopy = false + withAnimation(.smooth(duration: 0.25)) { + self.didCopy = false + } } }) } label: { Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc") .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) + .foregroundStyle(self.didCopy ? Color.green : Color.secondary) + .contentTransition(.symbolEffect(.replace)) .frame(width: 18, height: 18) .contentShape(Rectangle()) } From 9b6152943a6dce857ae6565e44f1d1f0a26a8e7e Mon Sep 17 00:00:00 2001 From: Elijah Friedman Date: Thu, 18 Jun 2026 21:48:15 -0400 Subject: [PATCH 5/9] revert animation --- Sources/CodexBar/StorageBreakdownMenuView.swift | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index 0489057376..2f2f0d732a 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -320,21 +320,16 @@ struct StoragePathCopyButton: View { Button { self.resetTask?.cancel() MenuPasteboardCopy.perform(self.path, completion: { - withAnimation(.smooth(duration: 0.25)) { - self.didCopy = true - } + self.didCopy = true self.resetTask = Task { @MainActor in try? await Task.sleep(for: .seconds(0.9)) - withAnimation(.smooth(duration: 0.25)) { - self.didCopy = false - } + self.didCopy = false } }) } label: { Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc") .font(.caption2.weight(.semibold)) - .foregroundStyle(self.didCopy ? Color.green : Color.secondary) - .contentTransition(.symbolEffect(.replace)) + .foregroundStyle(.secondary) .frame(width: 18, height: 18) .contentShape(Rectangle()) } From f1323e615df952b75a52849af38bae572f909414 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 18 Jun 2026 23:02:31 -0400 Subject: [PATCH 6/9] fix: complete segmented storage breakdown Co-authored-by: Elijah Friedman --- CHANGELOG.md | 1 + .../Resources/ar.lproj/Localizable.strings | 8 ++ .../Resources/ca.lproj/Localizable.strings | 11 +++ .../Resources/de.lproj/Localizable.strings | 11 +++ .../Resources/en.lproj/Localizable.strings | 8 ++ .../Resources/es.lproj/Localizable.strings | 11 +++ .../Resources/fa.lproj/Localizable.strings | 8 ++ .../Resources/fr.lproj/Localizable.strings | 11 +++ .../Resources/id.lproj/Localizable.strings | 8 ++ .../Resources/it.lproj/Localizable.strings | 8 ++ .../Resources/ja.lproj/Localizable.strings | 11 +++ .../Resources/ko.lproj/Localizable.strings | 11 +++ .../Resources/nl.lproj/Localizable.strings | 11 +++ .../Resources/pl.lproj/Localizable.strings | 8 ++ .../Resources/pt-BR.lproj/Localizable.strings | 11 +++ .../Resources/sv.lproj/Localizable.strings | 11 +++ .../Resources/th.lproj/Localizable.strings | 8 ++ .../Resources/tr.lproj/Localizable.strings | 8 ++ .../Resources/uk.lproj/Localizable.strings | 11 +++ .../Resources/vi.lproj/Localizable.strings | 11 +++ .../zh-Hans.lproj/Localizable.strings | 11 +++ .../zh-Hant.lproj/Localizable.strings | 11 +++ .../CodexBar/StorageBreakdownMenuView.swift | 37 +++++-- Sources/CodexBarCore/UsageFormatter.swift | 27 +++-- .../StorageBreakdownSegmentTests.swift | 99 +++++++++++++++++++ Tests/CodexBarTests/UsageFormatterTests.swift | 24 +++++ 26 files changed, 380 insertions(+), 15 deletions(-) create mode 100644 Tests/CodexBarTests/StorageBreakdownSegmentTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 8beb2719d1..12029b38ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Codex: show available manual rate-limit reset credits and their next expiry for signed-in OAuth accounts. Thanks @rogdex24! - Mistral: add Vibe monthly-plan usage and menu bar metric selection. Thanks @lfmundim! +- Storage: show a compact segmented provider breakdown with an expandable Other group. Thanks @elijahfriedman! - Linux CLI: publish static musl release tarballs for x86_64 and aarch64. Thanks @Yuxin-Qiao! - Documentation: add safe troubleshooting for browser Keychain prompts that persist after uninstall. Thanks @Yuxin-Qiao! - Codex agents: add a read-only `codexbar` skill for bounded, redacted provider usage JSON. Thanks @coygeek! diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index c7b6c897af..e9d102803e 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1096,3 +1096,11 @@ "1 manual reset available" = "تتوفر إعادة تعيين يدوية واحدة"; "%d manual resets available" = "تتوفر %d عمليات إعادة تعيين يدوية"; "Next expires %@" = "تنتهي صلاحية التالية %@"; +"byte_unit_byte" = "بايت"; +"byte_unit_bytes" = "بايتات"; +"byte_unit_kilobyte" = "كيلوبايت"; +"byte_unit_kilobytes" = "كيلوبايتات"; +"byte_unit_megabyte" = "ميغابايت"; +"byte_unit_megabytes" = "ميغابايتات"; +"byte_unit_gigabyte" = "غيغابايت"; +"byte_unit_gigabytes" = "غيغابايتات"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index c647beb82c..b1b4b5914e 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -948,3 +948,14 @@ "1 manual reset available" = "Hi ha 1 restabliment manual disponible"; "%d manual resets available" = "Hi ha %d restabliments manuals disponibles"; "Next expires %@" = "El següent caduca %@"; +"Other (%d items)" = "Altres (%d elements)"; +"Expand" = "Amplia"; +"Collapse" = "Redueix"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "quilobyte"; +"byte_unit_kilobytes" = "quilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index b3481e9f61..2338f25f90 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1090,3 +1090,14 @@ "1 manual reset available" = "1 manuelle Zurücksetzung verfügbar"; "%d manual resets available" = "%d manuelle Zurücksetzungen verfügbar"; "Next expires %@" = "Nächster Ablauf %@"; +"Other (%d items)" = "Andere (%d Elemente)"; +"Expand" = "Aufklappen"; +"Collapse" = "Zuklappen"; +"byte_unit_byte" = "Byte"; +"byte_unit_bytes" = "Byte"; +"byte_unit_kilobyte" = "Kilobyte"; +"byte_unit_kilobytes" = "Kilobyte"; +"byte_unit_megabyte" = "Megabyte"; +"byte_unit_megabytes" = "Megabyte"; +"byte_unit_gigabyte" = "Gigabyte"; +"byte_unit_gigabytes" = "Gigabyte"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index dc248e7ec1..1a43931785 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1096,3 +1096,11 @@ "1 manual reset available" = "1 manual reset available"; "%d manual resets available" = "%d manual resets available"; "Next expires %@" = "Next expires %@"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 3719c2b883..4af0efe0c7 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -948,3 +948,14 @@ "1 manual reset available" = "1 restablecimiento manual disponible"; "%d manual resets available" = "%d restablecimientos manuales disponibles"; "Next expires %@" = "El siguiente caduca %@"; +"Other (%d items)" = "Otros (%d elementos)"; +"Expand" = "Expandir"; +"Collapse" = "Contraer"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 6e2eaff3a0..18cf1c37b4 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1096,3 +1096,11 @@ "1 manual reset available" = "۱ بازنشانی دستی موجود است"; "%d manual resets available" = "%d بازنشانی دستی موجود است"; "Next expires %@" = "مورد بعدی در %@ منقضی می‌شود"; +"byte_unit_byte" = "بایت"; +"byte_unit_bytes" = "بایت"; +"byte_unit_kilobyte" = "کیلوبایت"; +"byte_unit_kilobytes" = "کیلوبایت"; +"byte_unit_megabyte" = "مگابایت"; +"byte_unit_megabytes" = "مگابایت"; +"byte_unit_gigabyte" = "گیگابایت"; +"byte_unit_gigabytes" = "گیگابایت"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index f44a86ae5b..4b40f1d092 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1088,3 +1088,14 @@ "1 manual reset available" = "1 réinitialisation manuelle disponible"; "%d manual resets available" = "%d réinitialisations manuelles disponibles"; "Next expires %@" = "Prochaine expiration %@"; +"Other (%d items)" = "Autres (%d éléments)"; +"Expand" = "Développer"; +"Collapse" = "Réduire"; +"byte_unit_byte" = "octet"; +"byte_unit_bytes" = "octets"; +"byte_unit_kilobyte" = "kilooctet"; +"byte_unit_kilobytes" = "kilooctets"; +"byte_unit_megabyte" = "mégaoctet"; +"byte_unit_megabytes" = "mégaoctets"; +"byte_unit_gigabyte" = "gigaoctet"; +"byte_unit_gigabytes" = "gigaoctets"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index b5b7053b6d..79b72a6137 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1095,3 +1095,11 @@ "1 manual reset available" = "1 pengaturan ulang manual tersedia"; "%d manual resets available" = "%d pengaturan ulang manual tersedia"; "Next expires %@" = "Berikutnya kedaluwarsa %@"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "byte"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobyte"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabyte"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabyte"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 192c923592..427db1c880 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1095,3 +1095,11 @@ "1 manual reset available" = "1 reimpostazione manuale disponibile"; "%d manual resets available" = "%d reimpostazioni manuali disponibili"; "Next expires %@" = "La prossima scade %@"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "byte"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobyte"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabyte"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabyte"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 72f0dc7700..09f552e0ab 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1089,3 +1089,14 @@ "1 manual reset available" = "手動リセットが1回利用可能"; "%d manual resets available" = "手動リセットが%d回利用可能"; "Next expires %@" = "次回の有効期限:%@"; +"Other (%d items)" = "その他(%d項目)"; +"Expand" = "展開"; +"Collapse" = "折りたたむ"; +"byte_unit_byte" = "バイト"; +"byte_unit_bytes" = "バイト"; +"byte_unit_kilobyte" = "キロバイト"; +"byte_unit_kilobytes" = "キロバイト"; +"byte_unit_megabyte" = "メガバイト"; +"byte_unit_megabytes" = "メガバイト"; +"byte_unit_gigabyte" = "ギガバイト"; +"byte_unit_gigabytes" = "ギガバイト"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index f08088b638..416fb29002 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1059,3 +1059,14 @@ "1 manual reset available" = "수동 재설정 1회 사용 가능"; "%d manual resets available" = "수동 재설정 %d회 사용 가능"; "Next expires %@" = "다음 만료: %@"; +"Other (%d items)" = "기타(%d개 항목)"; +"Expand" = "펼치기"; +"Collapse" = "접기"; +"byte_unit_byte" = "바이트"; +"byte_unit_bytes" = "바이트"; +"byte_unit_kilobyte" = "킬로바이트"; +"byte_unit_kilobytes" = "킬로바이트"; +"byte_unit_megabyte" = "메가바이트"; +"byte_unit_megabytes" = "메가바이트"; +"byte_unit_gigabyte" = "기가바이트"; +"byte_unit_gigabytes" = "기가바이트"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 070b0fc220..5dac19b09b 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1088,3 +1088,14 @@ "1 manual reset available" = "1 handmatige reset beschikbaar"; "%d manual resets available" = "%d handmatige resets beschikbaar"; "Next expires %@" = "Volgende verloopt %@"; +"Other (%d items)" = "Overig (%d onderdelen)"; +"Expand" = "Uitvouwen"; +"Collapse" = "Invouwen"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index a172bbdf56..ffd3b1e042 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1095,3 +1095,11 @@ "1 manual reset available" = "Dostępny 1 ręczny reset"; "%d manual resets available" = "Dostępne ręczne resety: %d"; "Next expires %@" = "Następny wygasa %@"; +"byte_unit_byte" = "bajt"; +"byte_unit_bytes" = "bajty"; +"byte_unit_kilobyte" = "kilobajt"; +"byte_unit_kilobytes" = "kilobajty"; +"byte_unit_megabyte" = "megabajt"; +"byte_unit_megabytes" = "megabajty"; +"byte_unit_gigabyte" = "gigabajt"; +"byte_unit_gigabytes" = "gigabajty"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 6840a7f391..d4da92c563 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1089,3 +1089,14 @@ "1 manual reset available" = "1 redefinição manual disponível"; "%d manual resets available" = "%d redefinições manuais disponíveis"; "Next expires %@" = "Próximo expira %@"; +"Other (%d items)" = "Outros (%d itens)"; +"Expand" = "Expandir"; +"Collapse" = "Recolher"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "quilobyte"; +"byte_unit_kilobytes" = "quilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index eb95ef3403..566b725149 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1087,3 +1087,14 @@ "1 manual reset available" = "1 manuell återställning tillgänglig"; "%d manual resets available" = "%d manuella återställningar tillgängliga"; "Next expires %@" = "Nästa upphör %@"; +"Other (%d items)" = "Övrigt (%d objekt)"; +"Expand" = "Expandera"; +"Collapse" = "Fäll ihop"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "byte"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobyte"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabyte"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabyte"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 4cf46bfc60..7c3427c467 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1096,3 +1096,11 @@ "1 manual reset available" = "มีการรีเซ็ตด้วยตนเอง 1 ครั้ง"; "%d manual resets available" = "มีการรีเซ็ตด้วยตนเอง %d ครั้ง"; "Next expires %@" = "รายการถัดไปหมดอายุ %@"; +"byte_unit_byte" = "ไบต์"; +"byte_unit_bytes" = "ไบต์"; +"byte_unit_kilobyte" = "กิโลไบต์"; +"byte_unit_kilobytes" = "กิโลไบต์"; +"byte_unit_megabyte" = "เมกะไบต์"; +"byte_unit_megabytes" = "เมกะไบต์"; +"byte_unit_gigabyte" = "กิกะไบต์"; +"byte_unit_gigabytes" = "กิกะไบต์"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index d019df947a..641bda3bcd 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1093,3 +1093,11 @@ "1 manual reset available" = "1 manuel sıfırlama kullanılabilir"; "%d manual resets available" = "%d manuel sıfırlama kullanılabilir"; "Next expires %@" = "Sonraki sona erme %@"; +"byte_unit_byte" = "bayt"; +"byte_unit_bytes" = "bayt"; +"byte_unit_kilobyte" = "kilobayt"; +"byte_unit_kilobytes" = "kilobayt"; +"byte_unit_megabyte" = "megabayt"; +"byte_unit_megabytes" = "megabayt"; +"byte_unit_gigabyte" = "gigabayt"; +"byte_unit_gigabytes" = "gigabayt"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 57d217f3cf..0afe5b3e9a 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1088,3 +1088,14 @@ "1 manual reset available" = "Доступне 1 ручне скидання"; "%d manual resets available" = "Доступно ручних скидань: %d"; "Next expires %@" = "Наступне спливає %@"; +"Other (%d items)" = "Інше (%d елементів)"; +"Expand" = "Розгорнути"; +"Collapse" = "Згорнути"; +"byte_unit_byte" = "байт"; +"byte_unit_bytes" = "байти"; +"byte_unit_kilobyte" = "кілобайт"; +"byte_unit_kilobytes" = "кілобайти"; +"byte_unit_megabyte" = "мегабайт"; +"byte_unit_megabytes" = "мегабайти"; +"byte_unit_gigabyte" = "гігабайт"; +"byte_unit_gigabytes" = "гігабайти"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 259f670b71..63f01897c9 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1089,3 +1089,14 @@ "1 manual reset available" = "Có 1 lần đặt lại thủ công"; "%d manual resets available" = "Có %d lần đặt lại thủ công"; "Next expires %@" = "Lượt tiếp theo hết hạn %@"; +"Other (%d items)" = "Khác (%d mục)"; +"Expand" = "Mở rộng"; +"Collapse" = "Thu gọn"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "byte"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobyte"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabyte"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabyte"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 3e2a6b3e13..53f2bcd6dd 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1065,3 +1065,14 @@ "1 manual reset available" = "1 次手动重置可用"; "%d manual resets available" = "%d 次手动重置可用"; "Next expires %@" = "下一个将于 %@ 到期"; +"Other (%d items)" = "其他(%d 项)"; +"Expand" = "展开"; +"Collapse" = "收起"; +"byte_unit_byte" = "字节"; +"byte_unit_bytes" = "字节"; +"byte_unit_kilobyte" = "千字节"; +"byte_unit_kilobytes" = "千字节"; +"byte_unit_megabyte" = "兆字节"; +"byte_unit_megabytes" = "兆字节"; +"byte_unit_gigabyte" = "吉字节"; +"byte_unit_gigabytes" = "吉字节"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 9656a390a2..4583a76ace 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -961,3 +961,14 @@ "1 manual reset available" = "可使用 1 次手動重設"; "%d manual resets available" = "可使用 %d 次手動重設"; "Next expires %@" = "下一個將於 %@ 到期"; +"Other (%d items)" = "其他(%d 個項目)"; +"Expand" = "展開"; +"Collapse" = "收合"; +"byte_unit_byte" = "位元組"; +"byte_unit_bytes" = "位元組"; +"byte_unit_kilobyte" = "千位元組"; +"byte_unit_kilobytes" = "千位元組"; +"byte_unit_megabyte" = "百萬位元組"; +"byte_unit_megabytes" = "百萬位元組"; +"byte_unit_gigabyte" = "十億位元組"; +"byte_unit_gigabytes" = "十億位元組"; diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index 2f2f0d732a..ceae93dcfd 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -68,7 +68,7 @@ struct StorageBreakdownMenuView: View { var copyablePaths: [String] { let recommendationPaths = self.cleanupRecommendations.map(\.path) - return self.segments.compactMap(\.path) + recommendationPaths + return self.footprint.components.map(\.path) + recommendationPaths } /// Visible components mapped to colored segments, with any tail beyond `maxRows` folded into a @@ -85,7 +85,7 @@ struct StorageBreakdownMenuView: View { Segment( id: component.id, name: component.name, - bytes: component.totalBytes, + bytes: max(component.totalBytes, 0), color: color(index), path: component.path) } @@ -96,7 +96,11 @@ struct StorageBreakdownMenuView: View { let visible = components.prefix(Self.maxRows - 1) let overflow = components.dropFirst(Self.maxRows - 1) - let otherBytes = overflow.reduce(Int64(0)) { $0 + $1.totalBytes } + let otherBytes = overflow.reduce(Int64(0)) { partial, component in + let bytes = max(component.totalBytes, 0) + let (sum, overflowed) = partial.addingReportingOverflow(bytes) + return overflowed ? .max : sum + } return visible.enumerated().map { segment($1, $0) } + [ Segment( id: "__other__", @@ -107,8 +111,8 @@ struct StorageBreakdownMenuView: View { ] } - private var segmentTotalBytes: Int64 { - max(self.segments.reduce(Int64(0)) { $0 + $1.bytes }, 1) + private var segmentTotalBytes: Double { + max(self.segments.reduce(0) { $0 + Double($1.bytes) }, 1) } /// The components folded into the trailing "Other" segment, revealed when it is expanded. @@ -205,7 +209,7 @@ struct StorageBreakdownMenuView: View { let reserved = minWidth * count guard barWidth > reserved else { return barWidth / max(count, 1) } let remainder = barWidth - reserved - let proportion = CGFloat(segment.bytes) / CGFloat(self.segmentTotalBytes) + let proportion = CGFloat(Double(segment.bytes) / self.segmentTotalBytes) return minWidth + remainder * proportion } @@ -267,6 +271,7 @@ struct StorageBreakdownMenuView: View { .truncationMode(.middle) .help(component.path) Spacer() + StoragePathCopyButton(path: component.path) Text(UsageFormatter.byteCountString(component.totalBytes)) .font(.caption) .foregroundStyle(.secondary) @@ -310,6 +315,26 @@ struct StorageBreakdownMenuView: View { } } +#if DEBUG +extension StorageBreakdownMenuView { + var _segmentNamesForTesting: [String] { + self.segments.map(\.name) + } + + var _segmentBytesForTesting: [Int64] { + self.segments.map(\.bytes) + } + + var _overflowNamesForTesting: [String] { + self.overflowComponents.map(\.name) + } + + func _segmentWidthsForTesting(barWidth: CGFloat) -> [CGFloat] { + self.segments.map { self.segmentWidth($0, barWidth: barWidth) } + } +} +#endif + struct StoragePathCopyButton: View { let path: String diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 88ab0dc88e..d3ab2c7bee 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -61,6 +61,14 @@ public enum UsageFormatter { case "usage_percent_suffix_left": return "left" case "usage_percent_suffix_used": return "used" case "reset_tomorrow_format": return "tomorrow, %@" + case "byte_unit_byte": return "byte" + case "byte_unit_bytes": return "bytes" + case "byte_unit_kilobyte": return "kilobyte" + case "byte_unit_kilobytes": return "kilobytes" + case "byte_unit_megabyte": return "megabyte" + case "byte_unit_megabytes": return "megabytes" + case "byte_unit_gigabyte": return "gigabyte" + case "byte_unit_gigabytes": return "gigabytes" default: return key } } @@ -246,7 +254,7 @@ public enum UsageFormatter { public static func byteCountString(_ bytes: Int64) -> String { let sign = bytes < 0 ? "-" : "" - let absBytes = Double(Swift.abs(bytes)) + let absBytes = Double(bytes.magnitude) let units: [(threshold: Double, divisor: Double, suffix: String)] = [ (1024 * 1024 * 1024, 1024 * 1024 * 1024, "GB"), (1024 * 1024, 1024 * 1024, "MB"), @@ -266,22 +274,23 @@ public enum UsageFormatter { /// Same magnitudes as `byteCountString`, but spelled out ("megabytes" instead of "MB"). public static func byteCountStringLong(_ bytes: Int64) -> String { let sign = bytes < 0 ? "-" : "" - let absBytes = Double(Swift.abs(bytes)) - let units: [(threshold: Double, divisor: Double, singular: String, plural: String)] = [ - (1024 * 1024 * 1024, 1024 * 1024 * 1024, "gigabyte", "gigabytes"), - (1024 * 1024, 1024 * 1024, "megabyte", "megabytes"), - (1024, 1024, "kilobyte", "kilobytes"), + let absBytes = Double(bytes.magnitude) + let units: [(threshold: Double, divisor: Double, singularKey: String, pluralKey: String)] = [ + (1024 * 1024 * 1024, 1024 * 1024 * 1024, "byte_unit_gigabyte", "byte_unit_gigabytes"), + (1024 * 1024, 1024 * 1024, "byte_unit_megabyte", "byte_unit_megabytes"), + (1024, 1024, "byte_unit_kilobyte", "byte_unit_kilobytes"), ] for unit in units where absBytes >= unit.threshold { let scaled = absBytes / unit.divisor let format = scaled >= 10 || scaled.rounded(.towardZero) == scaled ? "%.0f" : "%.1f" - let formatted = String(format: format, scaled) - let word = formatted == "1" ? unit.singular : unit.plural + let formatted = String(format: format, locale: self.currentLocale(), scaled) + let word = self.localized(scaled == 1 ? unit.singularKey : unit.pluralKey) return "\(sign)\(formatted) \(word)" } - return "\(bytes) \(Swift.abs(bytes) == 1 ? "byte" : "bytes")" + let word = self.localized(bytes.magnitude == 1 ? "byte_unit_byte" : "byte_unit_bytes") + return "\(bytes) \(word)" } public static func creditEventSummary(_ event: CreditEvent) -> String { diff --git a/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift new file mode 100644 index 0000000000..a7ecd439a3 --- /dev/null +++ b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift @@ -0,0 +1,99 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct StorageBreakdownSegmentTests { + @Test @MainActor + func `folds overflow into eighth segment without losing paths`() { + let components = (1...10).map { index in + ProviderStorageFootprint.Component(path: "/tmp/item-\(index)", totalBytes: Int64(index)) + } + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + + #expect(view._segmentNamesForTesting == [ + "item-1", "item-2", "item-3", "item-4", "item-5", "item-6", "item-7", "Other (3 items)", + ]) + #expect(view._segmentBytesForTesting == [1, 2, 3, 4, 5, 6, 7, 27]) + #expect(view._overflowNamesForTesting == ["item-8", "item-9", "item-10"]) + #expect(view.copyablePaths == components.map(\.path)) + } + + @Test @MainActor + func `segment widths fill bar and keep tiny values visible`() { + let components = [ + ProviderStorageFootprint.Component(path: "/tmp/large", totalBytes: 1_000_000), + ProviderStorageFootprint.Component(path: "/tmp/tiny", totalBytes: 1), + ProviderStorageFootprint.Component(path: "/tmp/zero", totalBytes: 0), + ] + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + let widths = view._segmentWidthsForTesting(barWidth: 100) + + #expect(widths.count == 3) + #expect(abs(widths.reduce(0, +) - 100) < 0.001) + #expect(widths.allSatisfy { $0 >= 2 }) + } + + @Test @MainActor + func `narrow bar divides width without overflow`() { + let components = (1...8).map { index in + ProviderStorageFootprint.Component(path: "/tmp/item-\(index)", totalBytes: 1) + } + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + let widths = view._segmentWidthsForTesting(barWidth: 8) + + #expect(widths == Array(repeating: 1, count: 8)) + #expect(widths.reduce(0, +) == 8) + } + + @Test @MainActor + func `negative component sizes clamp to zero`() { + let components = [ + ProviderStorageFootprint.Component(path: "/tmp/negative", totalBytes: -10), + ProviderStorageFootprint.Component(path: "/tmp/positive", totalBytes: 10), + ] + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + + #expect(view._segmentBytesForTesting == [0, 10]) + #expect(view._segmentWidthsForTesting(barWidth: 100).allSatisfy { $0 >= 2 }) + } + + @Test @MainActor + func `extreme component sizes still fill exactly one bar`() { + let components = [ + ProviderStorageFootprint.Component(path: "/tmp/first", totalBytes: .max), + ProviderStorageFootprint.Component(path: "/tmp/second", totalBytes: .max), + ] + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + let widths = view._segmentWidthsForTesting(barWidth: 100) + + #expect(abs(widths.reduce(0, +) - 100) < 0.001) + #expect(widths == [50, 50]) + } + + private static func footprint( + components: [ProviderStorageFootprint.Component]) -> ProviderStorageFootprint + { + ProviderStorageFootprint( + provider: .claude, + totalBytes: components.reduce(Int64(0)) { partial, component in + let (sum, overflowed) = partial.addingReportingOverflow(max(component.totalBytes, 0)) + return overflowed ? .max : sum + }, + paths: components.map(\.path), + missingPaths: [], + unreadablePaths: [], + components: components, + updatedAt: Date(timeIntervalSince1970: 0)) + } +} diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 94f3696ac7..16aded1481 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -17,6 +17,14 @@ struct UsageFormatterTests { "Updated just now", "usage_percent_suffix_left", "usage_percent_suffix_used", + "byte_unit_byte", + "byte_unit_bytes", + "byte_unit_kilobyte", + "byte_unit_kilobytes", + "byte_unit_megabyte", + "byte_unit_megabytes", + "byte_unit_gigabyte", + "byte_unit_gigabytes", ] @Test @@ -351,6 +359,22 @@ struct UsageFormatterTests { #expect(UsageFormatter.byteCountString(10 * 1024) == "10 KB") #expect(UsageFormatter.byteCountString(5 * 1024 * 1024) == "5 MB") #expect(UsageFormatter.byteCountString(Int64(1536 * 1024 * 1024)) == "1.5 GB") + #expect(UsageFormatter.byteCountString(.min) == "-8589934592 GB") + } + + @Test + func `long byte count string localizes units and handles boundaries`() { + UsageFormatter.clearLocalizationProvider() + #expect(UsageFormatter.byteCountStringLong(1024 * 1024) == "1 megabyte") + + UsageFormatter.setLocalizationProvider { "[\($0)]" } + defer { UsageFormatter.clearLocalizationProvider() } + + #expect(UsageFormatter.byteCountStringLong(1) == "1 [byte_unit_byte]") + #expect(UsageFormatter.byteCountStringLong(2) == "2 [byte_unit_bytes]") + #expect(UsageFormatter.byteCountStringLong(1536) == "1.5 [byte_unit_kilobytes]") + #expect(UsageFormatter.byteCountStringLong(1024 * 1024) == "1 [byte_unit_megabyte]") + #expect(UsageFormatter.byteCountStringLong(.min) == "-8589934592 [byte_unit_gigabytes]") } @Test From e2bf24fc9c58c47857fc2a9daf7e6d0766a37cb5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 18 Jun 2026 23:06:04 -0400 Subject: [PATCH 7/9] fix: fill zero-byte storage bars Co-authored-by: Elijah Friedman --- Sources/CodexBar/StorageBreakdownMenuView.swift | 3 ++- .../CodexBarTests/StorageBreakdownSegmentTests.swift | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index ceae93dcfd..6484e446f9 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -112,7 +112,7 @@ struct StorageBreakdownMenuView: View { } private var segmentTotalBytes: Double { - max(self.segments.reduce(0) { $0 + Double($1.bytes) }, 1) + self.segments.reduce(0) { $0 + Double($1.bytes) } } /// The components folded into the trailing "Other" segment, revealed when it is expanded. @@ -206,6 +206,7 @@ struct StorageBreakdownMenuView: View { private func segmentWidth(_ segment: Segment, barWidth: CGFloat) -> CGFloat { let minWidth: CGFloat = 2 let count = CGFloat(self.segments.count) + guard self.segmentTotalBytes > 0 else { return barWidth / max(count, 1) } let reserved = minWidth * count guard barWidth > reserved else { return barWidth / max(count, 1) } let remainder = barWidth - reserved diff --git a/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift index a7ecd439a3..4b371c9a8e 100644 --- a/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift +++ b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift @@ -52,6 +52,18 @@ struct StorageBreakdownSegmentTests { #expect(widths.reduce(0, +) == 8) } + @Test @MainActor + func `zero byte components evenly fill bar`() { + let components = (1...4).map { index in + ProviderStorageFootprint.Component(path: "/tmp/item-\(index)", totalBytes: 0) + } + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + + #expect(view._segmentWidthsForTesting(barWidth: 100) == [25, 25, 25, 25]) + } + @Test @MainActor func `negative component sizes clamp to zero`() { let components = [ From f5687176be5589d14d667d699701788a4f8edfb4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Jun 2026 01:15:59 -0400 Subject: [PATCH 8/9] fix: resize expanded storage breakdown --- .../StatusItemController+HostedSubmenus.swift | 21 +++++++++++++--- .../CodexBar/StorageBreakdownMenuView.swift | 25 ++++++++++++++++++- Sources/CodexBarCore/UsageFormatter.swift | 4 ++- .../StorageBreakdownSegmentTests.swift | 1 + Tests/CodexBarTests/UsageFormatterTests.swift | 1 + 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 0a148cbef2..b5506fae38 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -439,11 +439,24 @@ extension StatusItemController { } let maxHeight = self.storageBreakdownMenuMaxHeight() - let view = StorageBreakdownMenuView(footprint: footprint, width: width, maxHeight: maxHeight) + final class HostingRelay { + weak var hosting: MenuHostingView? + var collapsedHeight: CGFloat = 1 + } + let relay = HostingRelay() + let view = StorageBreakdownMenuView( + footprint: footprint, + width: width, + maxHeight: maxHeight, + onExpansionHeightChange: { additionalHeight in + relay.hosting?.applyMeasuredHeight( + width: width, + height: min(maxHeight, relay.collapsedHeight + additionalHeight)) + }) let hosting = MenuHostingView(rootView: view) - hosting.frame = NSRect( - origin: .zero, - size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) + relay.hosting = hosting + relay.collapsedHeight = self.hostedSubviewFittingHeight(for: hosting, width: width) + hosting.applyMeasuredHeight(width: width, height: relay.collapsedHeight) let item = NSMenuItem() item.view = hosting diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index 6484e446f9..5ab9e1ff90 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -28,13 +28,20 @@ struct StorageBreakdownMenuView: View { let footprint: ProviderStorageFootprint let width: CGFloat let maxHeight: CGFloat + let onExpansionHeightChange: ((CGFloat) -> Void)? @State private var otherExpanded = false - init(footprint: ProviderStorageFootprint, width: CGFloat, maxHeight: CGFloat = 560) { + init( + footprint: ProviderStorageFootprint, + width: CGFloat, + maxHeight: CGFloat = 560, + onExpansionHeightChange: ((CGFloat) -> Void)? = nil) + { self.footprint = footprint self.width = width self.maxHeight = maxHeight + self.onExpansionHeightChange = onExpansionHeightChange } /// One entry in the segmented bar and its matching legend row. Overflow components past the row @@ -61,6 +68,9 @@ struct StorageBreakdownMenuView: View { ] private static let otherColor = Color(nsColor: .tertiaryLabelColor) + private static let overflowRowHeight: CGFloat = 18 + private static let overflowRowSpacing: CGFloat = 4 + private static let overflowTopSpacing: CGFloat = 6 var cleanupRecommendations: [ProviderStorageRecommendation] { self.footprint.cleanupRecommendations @@ -122,6 +132,14 @@ struct StorageBreakdownMenuView: View { return Array(components.dropFirst(Self.maxRows - 1)) } + private var overflowExpansionHeight: CGFloat { + let count = CGFloat(self.overflowComponents.count) + guard count > 0 else { return 0 } + return Self.overflowTopSpacing + + count * Self.overflowRowHeight + + (count - 1) * Self.overflowRowSpacing + } + var body: some View { ScrollView(.vertical) { self.content @@ -248,6 +266,7 @@ struct StorageBreakdownMenuView: View { private var otherExpandButton: some View { Button { self.otherExpanded.toggle() + self.onExpansionHeightChange?(self.otherExpanded ? self.overflowExpansionHeight : 0) } label: { Image(systemName: self.otherExpanded ? "chevron.down" : "chevron.right") .font(.caption2.weight(.semibold)) @@ -330,6 +349,10 @@ extension StorageBreakdownMenuView { self.overflowComponents.map(\.name) } + var _overflowExpansionHeightForTesting: CGFloat { + self.overflowExpansionHeight + } + func _segmentWidthsForTesting(barWidth: CGFloat) -> [CGFloat] { self.segments.map { self.segmentWidth($0, barWidth: barWidth) } } diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index d3ab2c7bee..e6bcc6a62f 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -285,7 +285,9 @@ public enum UsageFormatter { let scaled = absBytes / unit.divisor let format = scaled >= 10 || scaled.rounded(.towardZero) == scaled ? "%.0f" : "%.1f" let formatted = String(format: format, locale: self.currentLocale(), scaled) - let word = self.localized(scaled == 1 ? unit.singularKey : unit.pluralKey) + let displayScale = format == "%.0f" ? 1.0 : 10.0 + let displayedValue = (scaled * displayScale).rounded() / displayScale + let word = self.localized(displayedValue == 1 ? unit.singularKey : unit.pluralKey) return "\(sign)\(formatted) \(word)" } diff --git a/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift index 4b371c9a8e..3198b53a64 100644 --- a/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift +++ b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift @@ -18,6 +18,7 @@ struct StorageBreakdownSegmentTests { ]) #expect(view._segmentBytesForTesting == [1, 2, 3, 4, 5, 6, 7, 27]) #expect(view._overflowNamesForTesting == ["item-8", "item-9", "item-10"]) + #expect(view._overflowExpansionHeightForTesting == 68) #expect(view.copyablePaths == components.map(\.path)) } diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 16aded1481..e973891236 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -374,6 +374,7 @@ struct UsageFormatterTests { #expect(UsageFormatter.byteCountStringLong(2) == "2 [byte_unit_bytes]") #expect(UsageFormatter.byteCountStringLong(1536) == "1.5 [byte_unit_kilobytes]") #expect(UsageFormatter.byteCountStringLong(1024 * 1024) == "1 [byte_unit_megabyte]") + #expect(UsageFormatter.byteCountStringLong(1024 * 1024 + 1) == "1.0 [byte_unit_megabyte]") #expect(UsageFormatter.byteCountStringLong(.min) == "-8589934592 [byte_unit_gigabytes]") } From 57ac6bae60f9500d042e197fa96d90e6ebd91f0c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Jun 2026 02:10:14 -0400 Subject: [PATCH 9/9] test: allow Italian byte unit loanwords --- Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index f2816b2073..d66f185213 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -274,6 +274,10 @@ struct LocalizationLanguageCatalogTests { "Password", "Provider", "Token", + "byte_unit_byte", + "byte_unit_gigabyte", + "byte_unit_kilobyte", + "byte_unit_megabyte", "language_arabic", "language_italian", "language_persian",