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 50e4da8e0d..e9d102803e 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 العناصر غير القابلة للقراءة تم تخطيها"; @@ -1093,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 a77d4ea033..1a43931785 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"; @@ -1093,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 9989373f59..18cf1c37b4 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 آیتم(های) غیرقابل خواندن رد شده اند"; @@ -1093,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 fcbca829a4..79b72a6137 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"; @@ -1092,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 5874929a33..427db1c880 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"; @@ -1092,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 7e6d1dd884..ffd3b1e042 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"; @@ -1092,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 0f31521014..7c3427c467 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 รายการ"; @@ -1093,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 145564c642..641bda3bcd 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"; @@ -1090,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/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 4e20eb66d1..5ab9e1ff90 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) @@ -27,28 +28,116 @@ 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 + /// 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) + private static let overflowRowHeight: CGFloat = 18 + private static let overflowRowSpacing: CGFloat = 4 + private static let overflowTopSpacing: CGFloat = 6 + var cleanupRecommendations: [ProviderStorageRecommendation] { self.footprint.cleanupRecommendations } var copyablePaths: [String] { let recommendationPaths = self.cleanupRecommendations.map(\.path) - return self.visibleComponents.map(\.path) + recommendationPaths + return self.footprint.components.map(\.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: max(component.totalBytes, 0), + 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)) { 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__", + name: String(format: L("Other (%d items)"), overflow.count), + bytes: otherBytes, + color: Self.otherColor, + path: nil), + ] + } + + private var segmentTotalBytes: Double { + self.segments.reduce(0) { $0 + Double($1.bytes) } } - 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)) + } + + 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 { @@ -70,28 +159,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 +200,106 @@ 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 + ZStack(alignment: .leading) { + Capsule() + .fill(Color(nsColor: .quaternaryLabelColor)) + HStack(spacing: 0) { + ForEach(self.segments) { segment in + Rectangle() + .fill(segment.color) + .frame(width: self.segmentWidth(segment, barWidth: proxy.size.width)) + } + } + } + .clipShape(Capsule()) + } + .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) + 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 + let proportion = CGFloat(Double(segment.bytes) / self.segmentTotalBytes) + return minWidth + remainder * proportion + } + + 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() + self.onExpansionHeightChange?(self.otherExpanded ? self.overflowExpansionHeight : 0) + } 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() + StoragePathCopyButton(path: component.path) + 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,12 +333,31 @@ struct StorageBreakdownMenuView: View { .fixedSize(horizontal: false, vertical: true) } } +} + +#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) + } + + var _overflowExpansionHeightForTesting: CGFloat { + self.overflowExpansionHeight + } - 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) + 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 09e2fcfd92..e6bcc6a62f 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"), @@ -263,6 +271,30 @@ 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(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, locale: self.currentLocale(), scaled) + 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)" + } + + let word = self.localized(bytes.magnitude == 1 ? "byte_unit_byte" : "byte_unit_bytes") + return "\(bytes) \(word)" + } + public static func creditEventSummary(_ event: CreditEvent) -> String { let formatter = DateFormatter() formatter.dateStyle = .medium 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", diff --git a/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift new file mode 100644 index 0000000000..3198b53a64 --- /dev/null +++ b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift @@ -0,0 +1,112 @@ +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._overflowExpansionHeightForTesting == 68) + #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 `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 = [ + 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..e973891236 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,23 @@ 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(1024 * 1024 + 1) == "1.0 [byte_unit_megabyte]") + #expect(UsageFormatter.byteCountStringLong(.min) == "-8589934592 [byte_unit_gigabytes]") } @Test