diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index e2bf38b88be8..0d400037615d 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -189,6 +189,7 @@ let package = Package( "CmuxNextFocusUI", "CmuxNextComputerUse", "CmuxNextAgentQuestion", + "CmuxNextDaemonRecovery", .product(name: "CmuxAgentQuestion", package: "CmuxAgentQuestion"), .product(name: "CmuxHomeCore", package: "CmuxHomeCore"), .product(name: "CmuxHomeRender", package: "CmuxHomeRender"), @@ -353,6 +354,16 @@ let package = Package( ), // The agent question card (plans/cmux-next/agent-questions.md): one // AppKit view and layout for Home rows, agent panes and the UI Gallery. + // The launch window's connecting and failure screen with Retry Now and + // Restart cmux-tui, and the App Store banner's recovery actions (cx-jniyz.1). + .target( + name: "CmuxNextDaemonRecovery", + dependencies: ["CmuxNextApps", "CmuxNextCompat", "CmuxNextDaemon", "CmuxNextDaemonService", "CmuxNextDesign", "CmuxNextWakeups"], + resources: [ + .process("Resources"), + ], + swiftSettings: uiSwiftSettings + ), .target( name: "CmuxNextAgentQuestion", dependencies: [ @@ -955,7 +966,7 @@ let package = Package( .testTarget( name: "CmuxNextAppTests", dependencies: ["CmuxNextWakeups", "CmuxNextNowPlaying", "CmuxNextLayout", "CmuxNextTasks", "CmuxNextAgentCursor", "CmuxNextTabs", "CmuxNextProcessEnvironment", "CmuxNextApp", "CmuxNextAgentLanes", "CmuxNextBrowserHistory", "CmuxNextCrashReporting", "CmuxNextActions", "CmuxNextHistory", "CmuxNextCopyMode", - "CmuxNextComputerUse", "CmuxNextDaemon", "CmuxNextDaemonService", "CmuxNextRemoteLocalhostService", "CmuxNextFocus", "CmuxNextFocusUI", "CmuxNextHome", "CmuxNextHomeEngine", .product(name: "CmuxHomeCore", package: "CmuxHomeCore"), + "CmuxNextComputerUse", "CmuxNextDaemon", "CmuxNextDaemonRecovery", "CmuxNextDaemonService", "CmuxNextRemoteLocalhostService", "CmuxNextFocus", "CmuxNextFocusUI", "CmuxNextHome", "CmuxNextHomeEngine", .product(name: "CmuxHomeCore", package: "CmuxHomeCore"), .product(name: "CmuxHomeRender", package: "CmuxHomeRender"), .product(name: "CmuxAgentQuestion", package: "CmuxAgentQuestion"), // GhosttyGlassMinimumContrastTests builds a Ghostty config. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Apps/AppsService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Apps/AppsService.swift index a7755325d3ae..c31e4cde36b2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Apps/AppsService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Apps/AppsService.swift @@ -2,6 +2,8 @@ import AppKit import CmuxNextActions import CmuxNextApps import CmuxNextControl +import CmuxNextDaemonRecovery +import CmuxNextDaemonService import CmuxNextIcons import CmuxNextPages import CmuxNextSettings @@ -34,8 +36,9 @@ final class AppsService { /// The App Store tabs (internal page), one store model per tab over the /// one client. Captures the client, not the service, so a page set that /// outlives the service still makes models. - private(set) lazy var storePages = AppStorePages { [weak self, client = self.client] in + private(set) lazy var storePages = AppStorePages { [weak self, client = self.client, recovery = self.services.daemon.recovery] in let model = AppStoreModel(client: client) + model.recovery = { recovery.appsRecovery } model.onNavigate = { self?.services.locationTrail.pageHistoryDidChange() } return model } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings index 6b20f7ccc6cf..9a0d3cbe590f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings @@ -265,4595 +265,3808 @@ } } }, - "daemon.copyDetails": { + "feed.bridge.title": { + "comment": "A phone banner title for an agent's permission request. %@ is the tool's title or kind (not translated).", "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copy Details" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "詳細をコピー" + "value": "Allow %@?" } }, "ar": { "stringUnit": { - "state": "translated", - "value": "نسخ التفاصيل" + "state": "needs_review", + "value": "Allow %@?" } }, "bs": { "stringUnit": { - "state": "translated", - "value": "Kopiraj detalje" + "state": "needs_review", + "value": "Allow %@?" } }, "da": { "stringUnit": { - "state": "translated", - "value": "Kopiér detaljer" + "state": "needs_review", + "value": "Allow %@?" } }, "de": { "stringUnit": { - "state": "translated", - "value": "Details kopieren" + "state": "needs_review", + "value": "%@ erlauben?" } }, "es": { "stringUnit": { - "state": "translated", - "value": "Copiar detalles" + "state": "needs_review", + "value": "¿Permitir %@?" } }, "fr": { "stringUnit": { - "state": "translated", - "value": "Copier les détails" + "state": "needs_review", + "value": "Autoriser %@ ?" } }, "it": { + "stringUnit": { + "state": "needs_review", + "value": "Consentire %@?" + } + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Copia dettagli" + "value": "%@ を許可しますか?" } }, "km": { "stringUnit": { - "state": "translated", - "value": "ចម្លងព័ត៌មានលម្អិត" + "state": "needs_review", + "value": "Allow %@?" } }, "ko": { "stringUnit": { - "state": "translated", - "value": "세부 정보 복사" + "state": "needs_review", + "value": "%@을(를) 허용할까요?" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "Kopier detaljer" + "state": "needs_review", + "value": "Allow %@?" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "Kopiuj szczegóły" + "state": "needs_review", + "value": "Allow %@?" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "Copiar detalhes" + "state": "needs_review", + "value": "Permitir %@?" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "Скопировать подробности" + "state": "needs_review", + "value": "Разрешить %@?" } }, "th": { "stringUnit": { - "state": "translated", - "value": "คัดลอกรายละเอียด" + "state": "needs_review", + "value": "Allow %@?" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "Ayrıntıları Kopyala" + "state": "needs_review", + "value": "Allow %@?" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "Скопіювати подробиці" + "state": "needs_review", + "value": "Allow %@?" } }, "vi": { "stringUnit": { - "state": "translated", - "value": "Sao chép chi tiết" + "state": "needs_review", + "value": "Allow %@?" } }, "zh-Hans": { "stringUnit": { - "state": "translated", - "value": "复制详细信息" + "state": "needs_review", + "value": "允许 %@?" } }, "zh-Hant": { "stringUnit": { - "state": "translated", - "value": "拷貝詳細資訊" + "state": "needs_review", + "value": "允許 %@?" } } } }, - "daemon.reinstall": { + "feed.bridge.summary": { + "comment": "The summary of an agent's permission request that has no tool title.", "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reinstall cmux to fix this." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux を再インストールすると解決します。" + "value": "An agent asks to run a tool" } }, "ar": { "stringUnit": { - "state": "translated", - "value": "أعد تثبيت cmux لإصلاح ذلك." + "state": "needs_review", + "value": "An agent asks to run a tool" } }, "bs": { "stringUnit": { - "state": "translated", - "value": "Ponovo instalirajte cmux da biste ovo popravili." + "state": "needs_review", + "value": "An agent asks to run a tool" } }, "da": { "stringUnit": { - "state": "translated", - "value": "Geninstaller cmux for at løse det." + "state": "needs_review", + "value": "An agent asks to run a tool" } }, "de": { "stringUnit": { - "state": "translated", - "value": "Installiere cmux neu, um das zu beheben." + "state": "needs_review", + "value": "Ein Agent möchte ein Werkzeug ausführen" } }, "es": { "stringUnit": { - "state": "translated", - "value": "Reinstala cmux para solucionarlo." + "state": "needs_review", + "value": "Un agente pide ejecutar una herramienta" } }, "fr": { "stringUnit": { - "state": "translated", - "value": "Réinstallez cmux pour corriger ce problème." + "state": "needs_review", + "value": "Un agent demande à exécuter un outil" } }, "it": { + "stringUnit": { + "state": "needs_review", + "value": "Un agente chiede di eseguire uno strumento" + } + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Reinstalla cmux per risolvere il problema." + "value": "エージェントがツールの実行を求めています" } }, "km": { "stringUnit": { - "state": "translated", - "value": "ដំឡើង cmux ឡើងវិញ ដើម្បីដោះស្រាយបញ្ហានេះ។" + "state": "needs_review", + "value": "An agent asks to run a tool" } }, "ko": { "stringUnit": { - "state": "translated", - "value": "cmux를 다시 설치하면 해결됩니다." + "state": "needs_review", + "value": "에이전트가 도구 실행을 요청합니다" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "Installer cmux på nytt for å løse dette." + "state": "needs_review", + "value": "An agent asks to run a tool" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "Zainstaluj cmux ponownie, aby to naprawić." + "state": "needs_review", + "value": "An agent asks to run a tool" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "Reinstale o cmux para corrigir isso." + "state": "needs_review", + "value": "Um agente pede para executar uma ferramenta" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "Переустановите cmux, чтобы это исправить." + "state": "needs_review", + "value": "Агент просит запустить инструмент" } }, "th": { "stringUnit": { - "state": "translated", - "value": "ติดตั้ง cmux ใหม่เพื่อแก้ไขปัญหานี้" + "state": "needs_review", + "value": "An agent asks to run a tool" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "Bunu düzeltmek için cmux’u yeniden yükleyin." + "state": "needs_review", + "value": "An agent asks to run a tool" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "Перевстановіть cmux, щоб це виправити." + "state": "needs_review", + "value": "An agent asks to run a tool" } }, "vi": { "stringUnit": { - "state": "translated", - "value": "Hãy cài đặt lại cmux để khắc phục." + "state": "needs_review", + "value": "An agent asks to run a tool" } }, "zh-Hans": { "stringUnit": { - "state": "translated", - "value": "重新安装 cmux 即可解决。" + "state": "needs_review", + "value": "代理请求运行工具" } }, "zh-Hant": { "stringUnit": { - "state": "translated", - "value": "重新安裝 cmux 即可解決。" + "state": "needs_review", + "value": "代理請求執行工具" } } } }, - "daemon.serviceUnavailable": { + "feed.bridge.poster": { + "comment": "Who posted an agent's permission request in the feed.", "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "cmux couldn’t start its background service." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux はバックグラウンドサービスを起動できませんでした。" + "value": "Agent" } }, "ar": { "stringUnit": { - "state": "translated", - "value": "تعذّر على cmux تشغيل خدمته في الخلفية." + "state": "needs_review", + "value": "Agent" } }, "bs": { "stringUnit": { - "state": "translated", - "value": "cmux nije mogao pokrenuti svoj pozadinski servis." + "state": "needs_review", + "value": "Agent" } }, "da": { "stringUnit": { - "state": "translated", - "value": "cmux kunne ikke starte sin baggrundstjeneste." + "state": "needs_review", + "value": "Agent" } }, "de": { "stringUnit": { - "state": "translated", - "value": "cmux konnte seinen Hintergrunddienst nicht starten." + "state": "needs_review", + "value": "Agent" } }, "es": { "stringUnit": { - "state": "translated", - "value": "cmux no pudo iniciar su servicio en segundo plano." + "state": "needs_review", + "value": "Agente" } }, "fr": { "stringUnit": { - "state": "translated", - "value": "cmux n’a pas pu démarrer son service d’arrière-plan." + "state": "needs_review", + "value": "Agent" } }, "it": { + "stringUnit": { + "state": "needs_review", + "value": "Agente" + } + }, + "ja": { "stringUnit": { "state": "translated", - "value": "cmux non è riuscito ad avviare il suo servizio in background." + "value": "エージェント" } }, "km": { "stringUnit": { - "state": "translated", - "value": "cmux មិនអាចចាប់ផ្តើមសេវាផ្ទៃខាងក្រោយរបស់វាបានទេ។" + "state": "needs_review", + "value": "Agent" } }, "ko": { "stringUnit": { - "state": "translated", - "value": "cmux가 백그라운드 서비스를 시작하지 못했습니다." + "state": "needs_review", + "value": "에이전트" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "cmux kunne ikke starte bakgrunnstjenesten sin." + "state": "needs_review", + "value": "Agent" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "cmux nie mógł uruchomić swojej usługi w tle." + "state": "needs_review", + "value": "Agent" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "O cmux não conseguiu iniciar seu serviço em segundo plano." + "state": "needs_review", + "value": "Agente" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "cmux не удалось запустить фоновую службу." + "state": "needs_review", + "value": "Агент" } }, "th": { "stringUnit": { - "state": "translated", - "value": "cmux ไม่สามารถเริ่มบริการเบื้องหลังได้" + "state": "needs_review", + "value": "Agent" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "cmux arka plan hizmetini başlatamadı." + "state": "needs_review", + "value": "Agent" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "cmux не вдалося запустити фонову службу." + "state": "needs_review", + "value": "Agent" } }, "vi": { "stringUnit": { - "state": "translated", - "value": "cmux không thể khởi động dịch vụ nền." + "state": "needs_review", + "value": "Agent" } }, "zh-Hans": { "stringUnit": { - "state": "translated", - "value": "cmux 无法启动其后台服务。" + "state": "needs_review", + "value": "代理" } }, "zh-Hant": { "stringUnit": { - "state": "translated", - "value": "cmux 無法啟動其背景服務。" + "state": "needs_review", + "value": "代理" } } } }, - "feed.bridge.title": { - "comment": "A phone banner title for an agent's permission request. %@ is the tool's title or kind (not translated).", + "menu.debug": { + "comment": "Menu bar menu, DEV and NIGHTLY builds only: debug actions.", "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Allow %@?" + "value": "Debug" } }, "ar": { "stringUnit": { "state": "needs_review", - "value": "Allow %@?" + "value": "تصحيح الأخطاء" } }, "bs": { "stringUnit": { "state": "needs_review", - "value": "Allow %@?" + "value": "Otklanjanje grešaka" } }, "da": { "stringUnit": { "state": "needs_review", - "value": "Allow %@?" + "value": "Fejlfinding" } }, "de": { "stringUnit": { "state": "needs_review", - "value": "%@ erlauben?" + "value": "Debug" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "¿Permitir %@?" + "value": "Depuración" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Autoriser %@ ?" + "value": "Débogage" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Consentire %@?" + "value": "Debug" } }, "ja": { "stringUnit": { - "state": "translated", - "value": "%@ を許可しますか?" + "state": "needs_review", + "value": "デバッグ" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "Allow %@?" + "value": "បំបាត់កំហុស" } }, "ko": { "stringUnit": { "state": "needs_review", - "value": "%@을(를) 허용할까요?" + "value": "디버그" } }, "nb": { "stringUnit": { "state": "needs_review", - "value": "Allow %@?" + "value": "Feilsøking" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Allow %@?" + "value": "Debugowanie" } }, "pt-BR": { "stringUnit": { "state": "needs_review", - "value": "Permitir %@?" + "value": "Depuração" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Разрешить %@?" + "value": "Отладка" } }, "th": { "stringUnit": { "state": "needs_review", - "value": "Allow %@?" + "value": "ดีบัก" } }, "tr": { "stringUnit": { "state": "needs_review", - "value": "Allow %@?" + "value": "Hata Ayıklama" } }, "uk": { "stringUnit": { "state": "needs_review", - "value": "Allow %@?" + "value": "Налагодження" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Allow %@?" + "value": "Gỡ lỗi" } }, "zh-Hans": { "stringUnit": { "state": "needs_review", - "value": "允许 %@?" + "value": "调试" } }, "zh-Hant": { "stringUnit": { "state": "needs_review", - "value": "允許 %@?" + "value": "除錯" } } } }, - "feed.bridge.summary": { - "comment": "The summary of an agent's permission request that has no tool title.", + "app.name": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "An agent asks to run a tool" + "value": "cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux" } }, "ar": { "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" + "state": "translated", + "value": "cmux" } }, "bs": { "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" + "state": "translated", + "value": "cmux" } }, "da": { "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" + "state": "translated", + "value": "cmux" } }, "de": { "stringUnit": { - "state": "needs_review", - "value": "Ein Agent möchte ein Werkzeug ausführen" + "state": "translated", + "value": "cmux" } }, "es": { "stringUnit": { - "state": "needs_review", - "value": "Un agente pide ejecutar una herramienta" + "state": "translated", + "value": "cmux" } }, "fr": { "stringUnit": { - "state": "needs_review", - "value": "Un agent demande à exécuter un outil" + "state": "translated", + "value": "cmux" } }, "it": { - "stringUnit": { - "state": "needs_review", - "value": "Un agente chiede di eseguire uno strumento" - } - }, - "ja": { "stringUnit": { "state": "translated", - "value": "エージェントがツールの実行を求めています" + "value": "cmux" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "An agent asks to run a tool" + "value": "cmux" } }, "ko": { "stringUnit": { - "state": "needs_review", - "value": "에이전트가 도구 실행을 요청합니다" + "state": "translated", + "value": "cmux" } }, "nb": { "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" + "state": "translated", + "value": "cmux" } }, "pl": { "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" + "state": "translated", + "value": "cmux" } }, "pt-BR": { "stringUnit": { - "state": "needs_review", - "value": "Um agente pede para executar uma ferramenta" + "state": "translated", + "value": "cmux" } }, "ru": { "stringUnit": { - "state": "needs_review", - "value": "Агент просит запустить инструмент" + "state": "translated", + "value": "cmux" } }, "th": { "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" + "state": "translated", + "value": "cmux" } }, "tr": { "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" + "state": "translated", + "value": "cmux" } }, "uk": { "stringUnit": { - "state": "needs_review", - "value": "An agent asks to run a tool" + "state": "translated", + "value": "cmux" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "An agent asks to run a tool" + "value": "cmux" } }, "zh-Hans": { "stringUnit": { - "state": "needs_review", - "value": "代理请求运行工具" + "state": "translated", + "value": "cmux" } }, "zh-Hant": { "stringUnit": { - "state": "needs_review", - "value": "代理請求執行工具" + "state": "translated", + "value": "cmux" } } } }, - "feed.bridge.poster": { - "comment": "Who posted an agent's permission request in the feed.", + "common.cancel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Agent" + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" } }, "ar": { "stringUnit": { - "state": "needs_review", - "value": "Agent" + "state": "translated", + "value": "إلغاء" } }, "bs": { "stringUnit": { - "state": "needs_review", - "value": "Agent" + "state": "translated", + "value": "Otkaži" } }, "da": { "stringUnit": { - "state": "needs_review", - "value": "Agent" + "state": "translated", + "value": "Annuller" } }, "de": { "stringUnit": { - "state": "needs_review", - "value": "Agent" + "state": "translated", + "value": "Abbrechen" } }, "es": { "stringUnit": { - "state": "needs_review", - "value": "Agente" + "state": "translated", + "value": "Cancelar" } }, "fr": { "stringUnit": { - "state": "needs_review", - "value": "Agent" + "state": "translated", + "value": "Annuler" } }, "it": { - "stringUnit": { - "state": "needs_review", - "value": "Agente" - } - }, - "ja": { "stringUnit": { "state": "translated", - "value": "エージェント" + "value": "Annulla" } }, "km": { "stringUnit": { - "state": "needs_review", - "value": "Agent" + "state": "translated", + "value": "បោះបង់" } }, "ko": { "stringUnit": { - "state": "needs_review", - "value": "에이전트" + "state": "translated", + "value": "취소" } }, "nb": { "stringUnit": { - "state": "needs_review", - "value": "Agent" + "state": "translated", + "value": "Avbryt" } }, "pl": { "stringUnit": { - "state": "needs_review", - "value": "Agent" + "state": "translated", + "value": "Anuluj" } }, "pt-BR": { "stringUnit": { - "state": "needs_review", - "value": "Agente" + "state": "translated", + "value": "Cancelar" } }, "ru": { "stringUnit": { - "state": "needs_review", - "value": "Агент" + "state": "translated", + "value": "Отменить" } }, "th": { "stringUnit": { - "state": "needs_review", - "value": "Agent" + "state": "translated", + "value": "ยกเลิก" } }, "tr": { "stringUnit": { - "state": "needs_review", - "value": "Agent" + "state": "translated", + "value": "Vazgeç" } }, "uk": { "stringUnit": { - "state": "needs_review", - "value": "Agent" + "state": "translated", + "value": "Скасувати" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Agent" + "value": "Hủy" } }, "zh-Hans": { "stringUnit": { - "state": "needs_review", - "value": "代理" + "state": "translated", + "value": "取消" } }, "zh-Hant": { "stringUnit": { - "state": "needs_review", - "value": "代理" + "state": "translated", + "value": "取消" } } } }, - "menu.debug": { - "comment": "Menu bar menu, DEV and NIGHTLY builds only: debug actions.", + "menu.app.hide": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Debug" + "value": "Hide cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux を非表示" } }, "ar": { "stringUnit": { "state": "needs_review", - "value": "تصحيح الأخطاء" + "value": "إخفاء cmux" } }, "bs": { "stringUnit": { "state": "needs_review", - "value": "Otklanjanje grešaka" + "value": "Sakrij cmux" } }, "da": { "stringUnit": { "state": "needs_review", - "value": "Fejlfinding" + "value": "Skjul cmux" } }, "de": { "stringUnit": { "state": "needs_review", - "value": "Debug" + "value": "cmux ausblenden" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Depuración" + "value": "Ocultar cmux" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Débogage" + "value": "Masquer cmux" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Debug" - } - }, - "ja": { - "stringUnit": { - "state": "needs_review", - "value": "デバッグ" + "value": "Nascondi cmux" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "បំបាត់កំហុស" + "value": "លាក់ cmux" } }, "ko": { "stringUnit": { "state": "needs_review", - "value": "디버그" + "value": "cmux 가리기" } }, "nb": { "stringUnit": { "state": "needs_review", - "value": "Feilsøking" + "value": "Skjul cmux" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Debugowanie" + "value": "Ukryj cmux" } }, "pt-BR": { "stringUnit": { "state": "needs_review", - "value": "Depuração" + "value": "Ocultar cmux" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Отладка" + "value": "Скрыть cmux" } }, "th": { "stringUnit": { "state": "needs_review", - "value": "ดีบัก" + "value": "ซ่อน cmux" } }, "tr": { "stringUnit": { "state": "needs_review", - "value": "Hata Ayıklama" + "value": "cmux’u Gizle" } }, "uk": { "stringUnit": { "state": "needs_review", - "value": "Налагодження" + "value": "Сховати cmux" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Gỡ lỗi" + "value": "Ẩn cmux" } }, "zh-Hans": { "stringUnit": { "state": "needs_review", - "value": "调试" + "value": "隐藏 cmux" } }, "zh-Hant": { "stringUnit": { "state": "needs_review", - "value": "除錯" + "value": "隱藏 cmux" } } } }, - "app.name": { + "menu.app.hideOthers": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "cmux" + "value": "Hide Others" } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux" + "value": "ほかを非表示" } }, "ar": { "stringUnit": { - "state": "translated", - "value": "cmux" + "state": "needs_review", + "value": "إخفاء الآخرين" } }, "bs": { "stringUnit": { - "state": "translated", - "value": "cmux" + "state": "needs_review", + "value": "Sakrij ostale" } }, "da": { "stringUnit": { - "state": "translated", - "value": "cmux" + "state": "needs_review", + "value": "Skjul andre" } }, "de": { "stringUnit": { - "state": "translated", - "value": "cmux" + "state": "needs_review", + "value": "Andere ausblenden" } }, "es": { "stringUnit": { - "state": "translated", - "value": "cmux" + "state": "needs_review", + "value": "Ocultar otros" } }, "fr": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - }, - "km": { - "stringUnit": { - "state": "needs_review", - "value": "cmux" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - }, - "vi": { - "stringUnit": { - "state": "needs_review", - "value": "cmux" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "cmux" - } - } - } - }, - "common.cancel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キャンセル" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "إلغاء" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Otkaži" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Annuller" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Abbrechen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cancelar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Annuler" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Annulla" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "បោះបង់" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "취소" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Avbryt" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Anuluj" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Cancelar" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Отменить" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ยกเลิก" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Vazgeç" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Скасувати" - } - }, - "vi": { - "stringUnit": { - "state": "needs_review", - "value": "Hủy" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "取消" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "取消" - } - } - } - }, - "menu.app.hide": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Hide cmux" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux を非表示" - } - }, - "ar": { - "stringUnit": { - "state": "needs_review", - "value": "إخفاء cmux" - } - }, - "bs": { - "stringUnit": { - "state": "needs_review", - "value": "Sakrij cmux" - } - }, - "da": { - "stringUnit": { - "state": "needs_review", - "value": "Skjul cmux" - } - }, - "de": { - "stringUnit": { - "state": "needs_review", - "value": "cmux ausblenden" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Ocultar cmux" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Masquer cmux" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nascondi cmux" - } - }, - "km": { - "stringUnit": { - "state": "needs_review", - "value": "លាក់ cmux" - } - }, - "ko": { - "stringUnit": { - "state": "needs_review", - "value": "cmux 가리기" - } - }, - "nb": { - "stringUnit": { - "state": "needs_review", - "value": "Skjul cmux" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Ukryj cmux" - } - }, - "pt-BR": { - "stringUnit": { - "state": "needs_review", - "value": "Ocultar cmux" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Скрыть cmux" - } - }, - "th": { - "stringUnit": { - "state": "needs_review", - "value": "ซ่อน cmux" - } - }, - "tr": { - "stringUnit": { - "state": "needs_review", - "value": "cmux’u Gizle" - } - }, - "uk": { - "stringUnit": { - "state": "needs_review", - "value": "Сховати cmux" - } - }, - "vi": { - "stringUnit": { - "state": "needs_review", - "value": "Ẩn cmux" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "needs_review", - "value": "隐藏 cmux" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "needs_review", - "value": "隱藏 cmux" - } - } - } - }, - "menu.app.hideOthers": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Hide Others" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ほかを非表示" - } - }, - "ar": { - "stringUnit": { - "state": "needs_review", - "value": "إخفاء الآخرين" - } - }, - "bs": { - "stringUnit": { - "state": "needs_review", - "value": "Sakrij ostale" - } - }, - "da": { - "stringUnit": { - "state": "needs_review", - "value": "Skjul andre" - } - }, - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Andere ausblenden" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Ocultar otros" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Masquer les autres" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nascondi altre" - } - }, - "km": { - "stringUnit": { - "state": "needs_review", - "value": "លាក់កម្មវិធីផ្សេងទៀត" - } - }, - "ko": { - "stringUnit": { - "state": "needs_review", - "value": "기타 가리기" - } - }, - "nb": { - "stringUnit": { - "state": "needs_review", - "value": "Skjul andre" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Ukryj pozostałe" - } - }, - "pt-BR": { - "stringUnit": { - "state": "needs_review", - "value": "Ocultar Outros" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Скрыть остальные" - } - }, - "th": { - "stringUnit": { - "state": "needs_review", - "value": "ซ่อนแอปอื่น" - } - }, - "tr": { - "stringUnit": { - "state": "needs_review", - "value": "Diğerlerini Gizle" - } - }, - "uk": { - "stringUnit": { - "state": "needs_review", - "value": "Сховати інші" - } - }, - "vi": { - "stringUnit": { - "state": "needs_review", - "value": "Ẩn các ứng dụng khác" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "needs_review", - "value": "隐藏其他" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "needs_review", - "value": "隱藏其他" - } - } - } - }, - "menu.app.showAll": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Show All" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべてを表示" - } - }, - "ar": { - "stringUnit": { - "state": "needs_review", - "value": "إظهار الكل" - } - }, - "bs": { - "stringUnit": { - "state": "needs_review", - "value": "Prikaži sve" - } - }, - "da": { - "stringUnit": { - "state": "needs_review", - "value": "Vis alle" - } - }, - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Alle einblenden" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Mostrar todo" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Tout afficher" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Mostra tutto" - } - }, - "km": { - "stringUnit": { - "state": "needs_review", - "value": "បង្ហាញទាំងអស់" - } - }, - "ko": { - "stringUnit": { - "state": "needs_review", - "value": "모두 보기" - } - }, - "nb": { - "stringUnit": { - "state": "needs_review", - "value": "Vis alle" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Pokaż wszystkie" - } - }, - "pt-BR": { - "stringUnit": { - "state": "needs_review", - "value": "Mostrar Tudo" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Показать все" - } - }, - "th": { - "stringUnit": { - "state": "needs_review", - "value": "แสดงทั้งหมด" - } - }, - "tr": { - "stringUnit": { - "state": "needs_review", - "value": "Tümünü Göster" - } - }, - "uk": { - "stringUnit": { - "state": "needs_review", - "value": "Показати всі" - } - }, - "vi": { - "stringUnit": { - "state": "needs_review", - "value": "Hiện tất cả" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "needs_review", - "value": "全部显示" - } - }, - "zh-Hant": { "stringUnit": { "state": "needs_review", - "value": "顯示全部" - } - } - } - }, - "menu.edit": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "編集" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "يحرر" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Bearbeiten" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Editar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Modifier" + "value": "Masquer les autres" } }, "it": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "편집하다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "vi": { "stringUnit": { "state": "needs_review", - "value": "Sửa" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "编辑" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "編輯" - } - } - } - }, - "menu.edit.copy": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "コピー" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "نسخ" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Kopiraj" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Kopiér" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Kopieren" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Copiar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Copier" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Copia" + "value": "Nascondi altre" } }, "km": { "stringUnit": { - "state": "translated", - "value": "ចម្លង" + "state": "needs_review", + "value": "លាក់កម្មវិធីផ្សេងទៀត" } }, "ko": { "stringUnit": { - "state": "translated", - "value": "복사" + "state": "needs_review", + "value": "기타 가리기" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "Kopier" + "state": "needs_review", + "value": "Skjul andre" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "Kopiuj" + "state": "needs_review", + "value": "Ukryj pozostałe" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "Copiar" + "state": "needs_review", + "value": "Ocultar Outros" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "Копировать" + "state": "needs_review", + "value": "Скрыть остальные" } }, "th": { "stringUnit": { - "state": "translated", - "value": "คัดลอก" + "state": "needs_review", + "value": "ซ่อนแอปอื่น" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "Kopyala" + "state": "needs_review", + "value": "Diğerlerini Gizle" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "Копіювати" + "state": "needs_review", + "value": "Сховати інші" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Sao chép" + "value": "Ẩn các ứng dụng khác" } }, "zh-Hans": { "stringUnit": { - "state": "translated", - "value": "复制" + "state": "needs_review", + "value": "隐藏其他" } }, "zh-Hant": { "stringUnit": { - "state": "translated", - "value": "複製" + "state": "needs_review", + "value": "隱藏其他" } } } }, - "menu.edit.cut": { + "menu.app.showAll": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cut" + "value": "Show All" } }, "ja": { "stringUnit": { "state": "translated", - "value": "カット" + "value": "すべてを表示" } }, "ar": { "stringUnit": { "state": "needs_review", - "value": "قص" + "value": "إظهار الكل" } }, "bs": { "stringUnit": { "state": "needs_review", - "value": "Izreži" + "value": "Prikaži sve" } }, "da": { "stringUnit": { "state": "needs_review", - "value": "Klip" + "value": "Vis alle" } }, "de": { "stringUnit": { "state": "needs_review", - "value": "Ausschneiden" + "value": "Alle einblenden" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Cortar" + "value": "Mostrar todo" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Couper" + "value": "Tout afficher" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Taglia" + "value": "Mostra tutto" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "កាត់" + "value": "បង្ហាញទាំងអស់" } }, "ko": { "stringUnit": { "state": "needs_review", - "value": "오려두기" + "value": "모두 보기" } }, "nb": { "stringUnit": { "state": "needs_review", - "value": "Klipp ut" + "value": "Vis alle" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Wytnij" + "value": "Pokaż wszystkie" } }, "pt-BR": { "stringUnit": { "state": "needs_review", - "value": "Recortar" + "value": "Mostrar Tudo" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Вырезать" + "value": "Показать все" } }, "th": { "stringUnit": { "state": "needs_review", - "value": "ตัด" + "value": "แสดงทั้งหมด" } }, "tr": { "stringUnit": { "state": "needs_review", - "value": "Kes" + "value": "Tümünü Göster" } }, "uk": { "stringUnit": { "state": "needs_review", - "value": "Вирізати" + "value": "Показати всі" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Cắt" + "value": "Hiện tất cả" } }, "zh-Hans": { "stringUnit": { "state": "needs_review", - "value": "剪切" + "value": "全部显示" } }, "zh-Hant": { "stringUnit": { "state": "needs_review", - "value": "剪下" + "value": "顯示全部" } } } }, - "menu.edit.paste": { + "menu.edit": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Paste" + "value": "Edit" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペースト" + "value": "編集" } }, "ar": { "stringUnit": { "state": "translated", - "value": "لصق" + "value": "يحرر" } }, "bs": { "stringUnit": { "state": "translated", - "value": "Zalijepi" + "value": "Edit" } }, "da": { "stringUnit": { "state": "translated", - "value": "Indsæt" + "value": "Edit" } }, "de": { "stringUnit": { "state": "translated", - "value": "Einfügen" + "value": "Bearbeiten" } }, "es": { "stringUnit": { "state": "translated", - "value": "Pegar" + "value": "Editar" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Coller" + "value": "Modifier" } }, "it": { "stringUnit": { "state": "translated", - "value": "Incolla" + "value": "Edit" } }, "km": { "stringUnit": { "state": "translated", - "value": "បិទភ្ជាប់" + "value": "Edit" } }, "ko": { "stringUnit": { "state": "translated", - "value": "붙여넣기" + "value": "편집하다" } }, "nb": { "stringUnit": { "state": "translated", - "value": "Lim inn" + "value": "Edit" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Wklej" + "value": "Edit" } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Colar" + "value": "Edit" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Вставить" + "value": "Edit" } }, "th": { "stringUnit": { "state": "translated", - "value": "วาง" + "value": "Edit" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Yapıştır" + "value": "Edit" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Вставити" + "value": "Edit" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Dán" + "value": "Sửa" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "粘贴" + "value": "编辑" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "貼上" + "value": "編輯" } } } }, - "menu.edit.redo": { + "menu.edit.copy": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Redo" + "value": "Copy" } }, "ja": { "stringUnit": { "state": "translated", - "value": "やり直す" + "value": "コピー" } }, "ar": { "stringUnit": { - "state": "needs_review", - "value": "إعادة" + "state": "translated", + "value": "نسخ" } }, "bs": { "stringUnit": { - "state": "needs_review", - "value": "Ponovi" + "state": "translated", + "value": "Kopiraj" } }, "da": { "stringUnit": { - "state": "needs_review", - "value": "Gentag" + "state": "translated", + "value": "Kopiér" } }, "de": { "stringUnit": { - "state": "needs_review", - "value": "Wiederholen" + "state": "translated", + "value": "Kopieren" } }, "es": { "stringUnit": { - "state": "needs_review", - "value": "Rehacer" + "state": "translated", + "value": "Copiar" } }, "fr": { "stringUnit": { - "state": "needs_review", - "value": "Rétablir" + "state": "translated", + "value": "Copier" } }, "it": { "stringUnit": { - "state": "needs_review", - "value": "Ripeti" + "state": "translated", + "value": "Copia" } }, "km": { "stringUnit": { - "state": "needs_review", - "value": "ធ្វើវិញ" + "state": "translated", + "value": "ចម្លង" } }, "ko": { "stringUnit": { - "state": "needs_review", - "value": "실행 복귀" + "state": "translated", + "value": "복사" } }, "nb": { "stringUnit": { - "state": "needs_review", - "value": "Gjør om" + "state": "translated", + "value": "Kopier" } }, "pl": { "stringUnit": { - "state": "needs_review", - "value": "Przywróć" + "state": "translated", + "value": "Kopiuj" } }, "pt-BR": { "stringUnit": { - "state": "needs_review", - "value": "Refazer" + "state": "translated", + "value": "Copiar" } }, "ru": { "stringUnit": { - "state": "needs_review", - "value": "Повторить" + "state": "translated", + "value": "Копировать" } }, "th": { "stringUnit": { - "state": "needs_review", - "value": "ทำซ้ำ" + "state": "translated", + "value": "คัดลอก" } }, "tr": { "stringUnit": { - "state": "needs_review", - "value": "Yinele" + "state": "translated", + "value": "Kopyala" } }, "uk": { "stringUnit": { - "state": "needs_review", - "value": "Повторити" + "state": "translated", + "value": "Копіювати" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Làm lại" + "value": "Sao chép" } }, "zh-Hans": { "stringUnit": { - "state": "needs_review", - "value": "重做" + "state": "translated", + "value": "复制" } }, "zh-Hant": { "stringUnit": { - "state": "needs_review", - "value": "重做" + "state": "translated", + "value": "複製" } } } }, - "menu.edit.selectAll": { + "menu.edit.cut": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Select All" + "value": "Cut" } }, "ja": { "stringUnit": { "state": "translated", - "value": "すべてを選択" + "value": "カット" } }, "ar": { "stringUnit": { "state": "needs_review", - "value": "تحديد الكل" + "value": "قص" } }, "bs": { "stringUnit": { "state": "needs_review", - "value": "Odaberi sve" + "value": "Izreži" } }, "da": { "stringUnit": { "state": "needs_review", - "value": "Vælg alt" + "value": "Klip" } }, "de": { "stringUnit": { "state": "needs_review", - "value": "Alles auswählen" + "value": "Ausschneiden" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Seleccionar todo" + "value": "Cortar" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Tout sélectionner" + "value": "Couper" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Seleziona tutto" + "value": "Taglia" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "ជ្រើសទាំងអស់" + "value": "កាត់" } }, "ko": { "stringUnit": { "state": "needs_review", - "value": "전체 선택" + "value": "오려두기" } }, "nb": { "stringUnit": { "state": "needs_review", - "value": "Marker alt" + "value": "Klipp ut" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Zaznacz wszystko" + "value": "Wytnij" } }, "pt-BR": { "stringUnit": { "state": "needs_review", - "value": "Selecionar Tudo" + "value": "Recortar" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Выбрать все" + "value": "Вырезать" } }, "th": { "stringUnit": { "state": "needs_review", - "value": "เลือกทั้งหมด" + "value": "ตัด" } }, "tr": { "stringUnit": { "state": "needs_review", - "value": "Tümünü Seç" + "value": "Kes" } }, "uk": { "stringUnit": { "state": "needs_review", - "value": "Вибрати все" + "value": "Вирізати" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Chọn tất cả" + "value": "Cắt" } }, "zh-Hans": { "stringUnit": { "state": "needs_review", - "value": "全选" + "value": "剪切" } }, "zh-Hant": { "stringUnit": { "state": "needs_review", - "value": "全選" + "value": "剪下" } } } }, - "menu.edit.undo": { + "menu.edit.paste": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Undo" + "value": "Paste" } }, "ja": { "stringUnit": { "state": "translated", - "value": "取り消す" + "value": "ペースト" } }, "ar": { "stringUnit": { "state": "translated", - "value": "تراجع" + "value": "لصق" } }, "bs": { "stringUnit": { - "state": "needs_review", - "value": "Poništi" + "state": "translated", + "value": "Zalijepi" } }, "da": { "stringUnit": { - "state": "needs_review", - "value": "Fortryd" + "state": "translated", + "value": "Indsæt" } }, "de": { "stringUnit": { "state": "translated", - "value": "Rückgängig" + "value": "Einfügen" } }, "es": { "stringUnit": { "state": "translated", - "value": "Deshacer" + "value": "Pegar" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Annuler" + "value": "Coller" } }, "it": { "stringUnit": { - "state": "needs_review", - "value": "Annulla" + "state": "translated", + "value": "Incolla" } }, "km": { "stringUnit": { - "state": "needs_review", - "value": "មិនធ្វើវិញ" + "state": "translated", + "value": "បិទភ្ជាប់" } }, "ko": { "stringUnit": { "state": "translated", - "value": "실행 취소" + "value": "붙여넣기" } }, "nb": { "stringUnit": { - "state": "needs_review", - "value": "Angre" + "state": "translated", + "value": "Lim inn" } }, "pl": { "stringUnit": { - "state": "needs_review", - "value": "Cofnij" + "state": "translated", + "value": "Wklej" } }, "pt-BR": { "stringUnit": { - "state": "needs_review", - "value": "Desfazer" + "state": "translated", + "value": "Colar" } }, "ru": { "stringUnit": { - "state": "needs_review", - "value": "Отменить" + "state": "translated", + "value": "Вставить" } }, "th": { "stringUnit": { - "state": "needs_review", - "value": "เลิกทำ" + "state": "translated", + "value": "วาง" } }, "tr": { "stringUnit": { - "state": "needs_review", - "value": "Geri Al" + "state": "translated", + "value": "Yapıştır" } }, "uk": { "stringUnit": { - "state": "needs_review", - "value": "Відмінити" + "state": "translated", + "value": "Вставити" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Hoàn tác" + "value": "Dán" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "撤销" + "value": "粘贴" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "復原" + "value": "貼上" } } } }, - "menu.file": { + "menu.edit.redo": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "File" + "value": "Redo" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ファイル" + "value": "やり直す" } }, "ar": { "stringUnit": { "state": "needs_review", - "value": "ملف" + "value": "إعادة" } }, "bs": { "stringUnit": { "state": "needs_review", - "value": "Datoteka" + "value": "Ponovi" } }, "da": { "stringUnit": { "state": "needs_review", - "value": "Arkiv" + "value": "Gentag" } }, "de": { "stringUnit": { "state": "needs_review", - "value": "Ablage" + "value": "Wiederholen" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Archivo" + "value": "Rehacer" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Fichier" + "value": "Rétablir" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "File" + "value": "Ripeti" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "ឯកសារ" + "value": "ធ្វើវិញ" } }, "ko": { "stringUnit": { "state": "needs_review", - "value": "파일" + "value": "실행 복귀" } }, "nb": { "stringUnit": { "state": "needs_review", - "value": "Arkiv" + "value": "Gjør om" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Plik" + "value": "Przywróć" } }, "pt-BR": { "stringUnit": { "state": "needs_review", - "value": "Arquivo" + "value": "Refazer" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Файл" + "value": "Повторить" } }, "th": { "stringUnit": { "state": "needs_review", - "value": "ไฟล์" + "value": "ทำซ้ำ" } }, "tr": { "stringUnit": { "state": "needs_review", - "value": "Dosya" + "value": "Yinele" } }, "uk": { "stringUnit": { "state": "needs_review", - "value": "Файл" + "value": "Повторити" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Tệp" + "value": "Làm lại" } }, "zh-Hans": { "stringUnit": { "state": "needs_review", - "value": "文件" + "value": "重做" } }, "zh-Hant": { "stringUnit": { "state": "needs_review", - "value": "檔案" + "value": "重做" } } } }, - "menu.view": { + "menu.edit.selectAll": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "View" + "value": "Select All" } }, "ja": { "stringUnit": { "state": "translated", - "value": "表示" + "value": "すべてを選択" } }, "ar": { "stringUnit": { - "state": "translated", - "value": "العرض" + "state": "needs_review", + "value": "تحديد الكل" } }, "bs": { "stringUnit": { - "state": "translated", - "value": "Prikaz" + "state": "needs_review", + "value": "Odaberi sve" } }, "da": { "stringUnit": { - "state": "translated", - "value": "Visning" + "state": "needs_review", + "value": "Vælg alt" } }, "de": { "stringUnit": { - "state": "translated", - "value": "Ansicht" + "state": "needs_review", + "value": "Alles auswählen" } }, "es": { "stringUnit": { - "state": "translated", - "value": "Vista" + "state": "needs_review", + "value": "Seleccionar todo" } }, "fr": { "stringUnit": { - "state": "translated", - "value": "Présentation" + "state": "needs_review", + "value": "Tout sélectionner" } }, "it": { "stringUnit": { - "state": "translated", - "value": "Vista" + "state": "needs_review", + "value": "Seleziona tutto" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "មើល" + "value": "ជ្រើសទាំងអស់" } }, "ko": { "stringUnit": { - "state": "translated", - "value": "보기" + "state": "needs_review", + "value": "전체 선택" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "Vis" + "state": "needs_review", + "value": "Marker alt" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "Widok" + "state": "needs_review", + "value": "Zaznacz wszystko" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "Visualização" + "state": "needs_review", + "value": "Selecionar Tudo" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "Вид" + "state": "needs_review", + "value": "Выбрать все" } }, "th": { "stringUnit": { - "state": "translated", - "value": "มุมมอง" + "state": "needs_review", + "value": "เลือกทั้งหมด" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "Görünüm" + "state": "needs_review", + "value": "Tümünü Seç" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "Перегляд" + "state": "needs_review", + "value": "Вибрати все" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Xem" + "value": "Chọn tất cả" } }, "zh-Hans": { "stringUnit": { - "state": "translated", - "value": "视图" + "state": "needs_review", + "value": "全选" } }, "zh-Hant": { "stringUnit": { - "state": "translated", - "value": "顯示方式" + "state": "needs_review", + "value": "全選" } } } }, - "menu.window": { + "menu.edit.undo": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Window" + "value": "Undo" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ウインドウ" + "value": "取り消す" } }, "ar": { "stringUnit": { "state": "translated", - "value": "نافذة" + "value": "تراجع" } }, "bs": { "stringUnit": { - "state": "translated", - "value": "Prozor" + "state": "needs_review", + "value": "Poništi" } }, "da": { "stringUnit": { - "state": "translated", - "value": "Vindue" + "state": "needs_review", + "value": "Fortryd" } }, "de": { "stringUnit": { "state": "translated", - "value": "Fenster" + "value": "Rückgängig" } }, "es": { "stringUnit": { "state": "translated", - "value": "Ventana" + "value": "Deshacer" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Fenêtre" + "value": "Annuler" } }, "it": { "stringUnit": { - "state": "translated", - "value": "Finestra" + "state": "needs_review", + "value": "Annulla" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "បង្អួច" + "value": "មិនធ្វើវិញ" } }, "ko": { "stringUnit": { "state": "translated", - "value": "윈도우" + "value": "실행 취소" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "Vindu" + "state": "needs_review", + "value": "Angre" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "Okno" + "state": "needs_review", + "value": "Cofnij" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "Janela" + "state": "needs_review", + "value": "Desfazer" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "Окно" + "state": "needs_review", + "value": "Отменить" } }, "th": { "stringUnit": { - "state": "translated", - "value": "หน้าต่าง" + "state": "needs_review", + "value": "เลิกทำ" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "Pencere" + "state": "needs_review", + "value": "Geri Al" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "Вікно" + "state": "needs_review", + "value": "Відмінити" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Cửa sổ" + "value": "Hoàn tác" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "窗口" + "value": "撤销" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "視窗" + "value": "復原" } } } }, - "menu.help": { - "comment": "The Help menu in the menu bar; macOS adds its help search field to it.", + "menu.file": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Help" + "value": "File" } }, - "ar": { + "ja": { "stringUnit": { "state": "translated", - "value": "مساعدة" + "value": "ファイル" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "ملف" } }, "bs": { "stringUnit": { - "state": "translated", - "value": "Pomoć" + "state": "needs_review", + "value": "Datoteka" } }, "da": { "stringUnit": { - "state": "translated", - "value": "Hjælp" + "state": "needs_review", + "value": "Arkiv" } }, "de": { "stringUnit": { - "state": "translated", - "value": "Hilfe" + "state": "needs_review", + "value": "Ablage" } }, "es": { "stringUnit": { - "state": "translated", - "value": "Ayuda" + "state": "needs_review", + "value": "Archivo" } }, "fr": { "stringUnit": { - "state": "translated", - "value": "Aide" + "state": "needs_review", + "value": "Fichier" } }, "it": { "stringUnit": { - "state": "translated", - "value": "Aiuto" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ヘルプ" + "state": "needs_review", + "value": "File" } }, "km": { "stringUnit": { - "state": "translated", - "value": "ជំនួយ" + "state": "needs_review", + "value": "ឯកសារ" } }, "ko": { "stringUnit": { - "state": "translated", - "value": "도움말" + "state": "needs_review", + "value": "파일" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "Hjelp" + "state": "needs_review", + "value": "Arkiv" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "Pomoc" + "state": "needs_review", + "value": "Plik" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "Ajuda" + "state": "needs_review", + "value": "Arquivo" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "Справка" + "state": "needs_review", + "value": "Файл" } }, "th": { "stringUnit": { - "state": "translated", - "value": "วิธีใช้" + "state": "needs_review", + "value": "ไฟล์" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "Yardım" + "state": "needs_review", + "value": "Dosya" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "Довідка" + "state": "needs_review", + "value": "Файл" } }, "vi": { "stringUnit": { - "state": "translated", - "value": "Trợ giúp" + "state": "needs_review", + "value": "Tệp" } }, "zh-Hans": { "stringUnit": { - "state": "translated", - "value": "帮助" + "state": "needs_review", + "value": "文件" } }, "zh-Hant": { "stringUnit": { - "state": "translated", - "value": "輔助說明" + "state": "needs_review", + "value": "檔案" } } } }, - "menu.window.minimize": { + "menu.view": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Minimize" + "value": "View" } }, "ja": { "stringUnit": { "state": "translated", - "value": "しまう" + "value": "表示" } }, "ar": { "stringUnit": { - "state": "needs_review", - "value": "تصغير" + "state": "translated", + "value": "العرض" } }, "bs": { "stringUnit": { - "state": "needs_review", - "value": "Minimiziraj" + "state": "translated", + "value": "Prikaz" } }, "da": { "stringUnit": { - "state": "needs_review", - "value": "Minimer" + "state": "translated", + "value": "Visning" } }, "de": { "stringUnit": { - "state": "needs_review", - "value": "Im Dock ablegen" + "state": "translated", + "value": "Ansicht" } }, "es": { "stringUnit": { - "state": "needs_review", - "value": "Minimizar" + "state": "translated", + "value": "Vista" } }, "fr": { "stringUnit": { - "state": "needs_review", - "value": "Placer dans le Dock" + "state": "translated", + "value": "Présentation" } }, "it": { "stringUnit": { - "state": "needs_review", - "value": "Contrai" + "state": "translated", + "value": "Vista" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "បង្រួមអប្បបរមា" + "value": "មើល" } }, "ko": { "stringUnit": { - "state": "needs_review", - "value": "최소화" + "state": "translated", + "value": "보기" } }, "nb": { "stringUnit": { - "state": "needs_review", - "value": "Minimer" + "state": "translated", + "value": "Vis" } }, "pl": { "stringUnit": { - "state": "needs_review", - "value": "Zminimalizuj" + "state": "translated", + "value": "Widok" } }, "pt-BR": { "stringUnit": { - "state": "needs_review", - "value": "Minimizar" + "state": "translated", + "value": "Visualização" } }, "ru": { "stringUnit": { - "state": "needs_review", - "value": "Свернуть" + "state": "translated", + "value": "Вид" } }, "th": { "stringUnit": { - "state": "needs_review", - "value": "ย่อ" + "state": "translated", + "value": "มุมมอง" } }, "tr": { "stringUnit": { - "state": "needs_review", - "value": "Küçült" + "state": "translated", + "value": "Görünüm" } }, "uk": { "stringUnit": { - "state": "needs_review", - "value": "Згорнути" + "state": "translated", + "value": "Перегляд" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Thu nhỏ" + "value": "Xem" } }, "zh-Hans": { "stringUnit": { - "state": "needs_review", - "value": "最小化" + "state": "translated", + "value": "视图" } }, "zh-Hant": { "stringUnit": { - "state": "needs_review", - "value": "縮到最小" + "state": "translated", + "value": "顯示方式" } } } }, - "menu.window.zoom": { + "menu.window": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Zoom" + "value": "Window" } }, "ja": { "stringUnit": { "state": "translated", - "value": "拡大/縮小" + "value": "ウインドウ" } }, "ar": { "stringUnit": { - "state": "needs_review", - "value": "تكبير/تصغير" + "state": "translated", + "value": "نافذة" } }, "bs": { "stringUnit": { - "state": "needs_review", - "value": "Zumiraj" + "state": "translated", + "value": "Prozor" } }, "da": { "stringUnit": { - "state": "needs_review", - "value": "Zoom" + "state": "translated", + "value": "Vindue" } }, "de": { "stringUnit": { - "state": "needs_review", - "value": "Zoomen" + "state": "translated", + "value": "Fenster" } }, "es": { "stringUnit": { - "state": "needs_review", - "value": "Zoom" + "state": "translated", + "value": "Ventana" } }, "fr": { "stringUnit": { - "state": "needs_review", - "value": "Réduire/agrandir" + "state": "translated", + "value": "Fenêtre" } }, "it": { "stringUnit": { - "state": "needs_review", - "value": "Zoom" + "state": "translated", + "value": "Finestra" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "ពង្រីក" + "value": "បង្អួច" } }, "ko": { "stringUnit": { - "state": "needs_review", - "value": "확대/축소" + "state": "translated", + "value": "윈도우" } }, "nb": { "stringUnit": { - "state": "needs_review", - "value": "Zoom" + "state": "translated", + "value": "Vindu" } }, "pl": { "stringUnit": { - "state": "needs_review", - "value": "Powiększ" + "state": "translated", + "value": "Okno" } }, "pt-BR": { "stringUnit": { - "state": "needs_review", - "value": "Zoom" + "state": "translated", + "value": "Janela" } }, "ru": { "stringUnit": { - "state": "needs_review", - "value": "Изменить масштаб" + "state": "translated", + "value": "Окно" } }, "th": { "stringUnit": { - "state": "needs_review", - "value": "ซูม" + "state": "translated", + "value": "หน้าต่าง" } }, "tr": { "stringUnit": { - "state": "needs_review", - "value": "Yakınlaştır/Uzaklaştır" + "state": "translated", + "value": "Pencere" } }, "uk": { "stringUnit": { - "state": "needs_review", - "value": "Масштаб" + "state": "translated", + "value": "Вікно" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Thu phóng" + "value": "Cửa sổ" } }, "zh-Hans": { "stringUnit": { - "state": "needs_review", - "value": "缩放" + "state": "translated", + "value": "窗口" } }, "zh-Hant": { "stringUnit": { - "state": "needs_review", - "value": "縮放" + "state": "translated", + "value": "視窗" } } } }, - "rename.confirm": { + "menu.window.minimize": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Rename" + "value": "Minimize" } }, "ja": { "stringUnit": { "state": "translated", - "value": "名前を変更" + "value": "しまう" } }, "ar": { "stringUnit": { - "state": "translated", - "value": "إعادة تسمية" + "state": "needs_review", + "value": "تصغير" } }, "bs": { "stringUnit": { - "state": "translated", - "value": "Preimenuj" + "state": "needs_review", + "value": "Minimiziraj" } }, "da": { "stringUnit": { - "state": "translated", - "value": "Omdøb" + "state": "needs_review", + "value": "Minimer" } }, "de": { "stringUnit": { - "state": "translated", - "value": "Umbenennen" + "state": "needs_review", + "value": "Im Dock ablegen" } }, "es": { "stringUnit": { - "state": "translated", - "value": "Renombrar" + "state": "needs_review", + "value": "Minimizar" } }, "fr": { "stringUnit": { - "state": "translated", - "value": "Renommer" + "state": "needs_review", + "value": "Placer dans le Dock" } }, "it": { "stringUnit": { - "state": "translated", - "value": "Rinomina" + "state": "needs_review", + "value": "Contrai" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "ប្ដូរឈ្មោះ" + "value": "បង្រួមអប្បបរមា" } }, "ko": { "stringUnit": { - "state": "translated", - "value": "이름 바꾸기" + "state": "needs_review", + "value": "최소화" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "Gi nytt navn" + "state": "needs_review", + "value": "Minimer" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "Zmień nazwę" + "state": "needs_review", + "value": "Zminimalizuj" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "Renomear" + "state": "needs_review", + "value": "Minimizar" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "Переименовать" + "state": "needs_review", + "value": "Свернуть" } }, "th": { "stringUnit": { - "state": "translated", - "value": "เปลี่ยนชื่อ" + "state": "needs_review", + "value": "ย่อ" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "Yeniden Adlandır" + "state": "needs_review", + "value": "Küçült" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "Перейменувати" + "state": "needs_review", + "value": "Згорнути" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Đổi tên" + "value": "Thu nhỏ" } }, "zh-Hans": { "stringUnit": { - "state": "translated", - "value": "重命名" + "state": "needs_review", + "value": "最小化" } }, "zh-Hant": { "stringUnit": { - "state": "translated", - "value": "重新命名" + "state": "needs_review", + "value": "縮到最小" } } } }, - "rename.tab.title": { + "menu.window.zoom": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Rename Tab" + "value": "Zoom" } }, "ja": { "stringUnit": { "state": "translated", - "value": "タブ名を変更" + "value": "拡大/縮小" } }, "ar": { "stringUnit": { - "state": "translated", - "value": "إعادة تسمية اللسان" + "state": "needs_review", + "value": "تكبير/تصغير" } }, "bs": { "stringUnit": { - "state": "translated", - "value": "Preimenuj tab" + "state": "needs_review", + "value": "Zumiraj" } }, "da": { "stringUnit": { - "state": "translated", - "value": "Omdøb fane" + "state": "needs_review", + "value": "Zoom" } }, "de": { "stringUnit": { - "state": "translated", - "value": "Tab umbenennen" + "state": "needs_review", + "value": "Zoomen" } }, "es": { "stringUnit": { - "state": "translated", - "value": "Renombrar pestaña" + "state": "needs_review", + "value": "Zoom" } }, "fr": { "stringUnit": { - "state": "translated", - "value": "Renommer l'onglet" + "state": "needs_review", + "value": "Réduire/agrandir" } }, "it": { "stringUnit": { - "state": "translated", - "value": "Rinomina scheda" + "state": "needs_review", + "value": "Zoom" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "ប្ដូរឈ្មោះថេប" + "value": "ពង្រីក" } }, "ko": { "stringUnit": { - "state": "translated", - "value": "탭 이름 변경" + "state": "needs_review", + "value": "확대/축소" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "Gi fanen nytt navn" + "state": "needs_review", + "value": "Zoom" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "Zmień nazwę karty" + "state": "needs_review", + "value": "Powiększ" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "Renomear Aba" + "state": "needs_review", + "value": "Zoom" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "Переименовать вкладку" + "state": "needs_review", + "value": "Изменить масштаб" } }, "th": { "stringUnit": { - "state": "translated", - "value": "เปลี่ยนชื่อแท็บ" + "state": "needs_review", + "value": "ซูม" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "Sekmeyi Yeniden Adlandır" + "state": "needs_review", + "value": "Yakınlaştır/Uzaklaştır" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "Перейменувати вкладку" + "state": "needs_review", + "value": "Масштаб" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Đổi tên tab" + "value": "Thu phóng" } }, "zh-Hans": { "stringUnit": { - "state": "translated", - "value": "重命名标签页" + "state": "needs_review", + "value": "缩放" } }, "zh-Hant": { "stringUnit": { - "state": "translated", - "value": "重新命名標籤頁" + "state": "needs_review", + "value": "縮放" } } } }, - "sidebar.machine.local": { + "rename.confirm": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This Mac" + "value": "Rename" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このMac" + "value": "名前を変更" } }, "ar": { "stringUnit": { "state": "translated", - "value": "جهاز Mac هذا" + "value": "إعادة تسمية" } }, "bs": { "stringUnit": { - "state": "needs_review", - "value": "Ovaj Mac" + "state": "translated", + "value": "Preimenuj" } }, "da": { "stringUnit": { - "state": "needs_review", - "value": "Denne Mac" + "state": "translated", + "value": "Omdøb" } }, "de": { "stringUnit": { "state": "translated", - "value": "Dieser Mac" + "value": "Umbenennen" } }, "es": { "stringUnit": { "state": "translated", - "value": "Este Mac" + "value": "Renombrar" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Ce Mac" + "value": "Renommer" } }, "it": { "stringUnit": { - "state": "needs_review", - "value": "Questo Mac" + "state": "translated", + "value": "Rinomina" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "Mac នេះ" + "value": "ប្ដូរឈ្មោះ" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이 Mac" + "value": "이름 바꾸기" } }, "nb": { "stringUnit": { - "state": "needs_review", - "value": "Denne Macen" + "state": "translated", + "value": "Gi nytt navn" } }, "pl": { "stringUnit": { - "state": "needs_review", - "value": "Ten Mac" + "state": "translated", + "value": "Zmień nazwę" } }, "pt-BR": { "stringUnit": { - "state": "needs_review", - "value": "Este Mac" + "state": "translated", + "value": "Renomear" } }, "ru": { "stringUnit": { - "state": "needs_review", - "value": "Этот Mac" + "state": "translated", + "value": "Переименовать" } }, "th": { "stringUnit": { - "state": "needs_review", - "value": "Mac เครื่องนี้" + "state": "translated", + "value": "เปลี่ยนชื่อ" } }, "tr": { "stringUnit": { - "state": "needs_review", - "value": "Bu Mac" + "state": "translated", + "value": "Yeniden Adlandır" } }, "uk": { "stringUnit": { - "state": "needs_review", - "value": "Цей Mac" + "state": "translated", + "value": "Перейменувати" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Máy Mac này" + "value": "Đổi tên" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "这台 Mac" + "value": "重命名" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "這台 Mac" + "value": "重新命名" } } } }, - "tab.untitled.browser": { + "rename.tab.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Tab" + "value": "Rename Tab" } }, "ja": { "stringUnit": { "state": "translated", - "value": "新規タブ" + "value": "タブ名を変更" } }, "ar": { "stringUnit": { "state": "translated", - "value": "علامة تبويب جديدة" + "value": "إعادة تسمية اللسان" } }, "bs": { "stringUnit": { - "state": "needs_review", - "value": "Novi tab" + "state": "translated", + "value": "Preimenuj tab" } }, "da": { "stringUnit": { - "state": "needs_review", - "value": "Ny fane" + "state": "translated", + "value": "Omdøb fane" } }, "de": { "stringUnit": { "state": "translated", - "value": "Neuer Tab" + "value": "Tab umbenennen" } }, "es": { "stringUnit": { "state": "translated", - "value": "Pestaña nueva" + "value": "Renombrar pestaña" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Nouvel onglet" + "value": "Renommer l'onglet" } }, "it": { "stringUnit": { - "state": "needs_review", - "value": "Nuova scheda" + "state": "translated", + "value": "Rinomina scheda" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "ថេបថ្មី" + "value": "ប្ដូរឈ្មោះថេប" } }, "ko": { "stringUnit": { "state": "translated", - "value": "새 탭" + "value": "탭 이름 변경" } }, "nb": { "stringUnit": { - "state": "needs_review", - "value": "Ny fane" + "state": "translated", + "value": "Gi fanen nytt navn" } }, "pl": { "stringUnit": { - "state": "needs_review", - "value": "Nowa karta" + "state": "translated", + "value": "Zmień nazwę karty" } }, "pt-BR": { "stringUnit": { - "state": "needs_review", - "value": "Nova Aba" + "state": "translated", + "value": "Renomear Aba" } }, "ru": { "stringUnit": { - "state": "needs_review", - "value": "Новая вкладка" + "state": "translated", + "value": "Переименовать вкладку" } }, "th": { "stringUnit": { - "state": "needs_review", - "value": "แท็บใหม่" + "state": "translated", + "value": "เปลี่ยนชื่อแท็บ" } }, "tr": { "stringUnit": { - "state": "needs_review", - "value": "Yeni Sekme" + "state": "translated", + "value": "Sekmeyi Yeniden Adlandır" } }, "uk": { "stringUnit": { - "state": "needs_review", - "value": "Нова вкладка" + "state": "translated", + "value": "Перейменувати вкладку" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Tab mới" + "value": "Đổi tên tab" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "新标签页" + "value": "重命名标签页" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "新標籤頁" + "value": "重新命名標籤頁" } } } }, - "notifications.reply.action": { - "comment": "A notification banner's Reply button (cmux notify --reply): it opens a text field whose text goes to the terminal.", + "sidebar.machine.local": { "extractionState": "manual", "localizations": { - "ar": { + "en": { "stringUnit": { "state": "translated", - "value": "رد" + "value": "This Mac" } }, - "bs": { + "ja": { "stringUnit": { "state": "translated", - "value": "Odgovori" + "value": "このMac" } }, - "da": { + "ar": { "stringUnit": { "state": "translated", - "value": "Svar" + "value": "جهاز Mac هذا" } }, - "de": { + "bs": { "stringUnit": { - "state": "translated", - "value": "Antworten" + "state": "needs_review", + "value": "Ovaj Mac" } }, - "en": { + "da": { "stringUnit": { - "state": "translated", - "value": "Reply" + "state": "needs_review", + "value": "Denne Mac" } }, - "es": { + "de": { "stringUnit": { "state": "translated", - "value": "Responder" + "value": "Dieser Mac" } }, - "fr": { + "es": { "stringUnit": { "state": "translated", - "value": "Répondre" + "value": "Este Mac" } }, - "it": { + "fr": { "stringUnit": { "state": "translated", - "value": "Rispondi" + "value": "Ce Mac" } }, - "ja": { + "it": { "stringUnit": { - "state": "translated", - "value": "返信" + "state": "needs_review", + "value": "Questo Mac" } }, "km": { "stringUnit": { - "state": "translated", - "value": "ឆ្លើយតប" + "state": "needs_review", + "value": "Mac នេះ" } }, "ko": { "stringUnit": { "state": "translated", - "value": "답장" + "value": "이 Mac" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "Svar" + "state": "needs_review", + "value": "Denne Macen" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "Odpowiedz" + "state": "needs_review", + "value": "Ten Mac" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "Responder" + "state": "needs_review", + "value": "Este Mac" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "Ответить" + "state": "needs_review", + "value": "Этот Mac" } }, "th": { "stringUnit": { - "state": "translated", - "value": "ตอบกลับ" + "state": "needs_review", + "value": "Mac เครื่องนี้" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "Yanıtla" + "state": "needs_review", + "value": "Bu Mac" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "Відповісти" + "state": "needs_review", + "value": "Цей Mac" } }, "vi": { "stringUnit": { - "state": "translated", - "value": "Trả lời" + "state": "needs_review", + "value": "Máy Mac này" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "回复" + "value": "这台 Mac" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "回覆" + "value": "這台 Mac" } } } }, - "notifications.reply.send": { - "comment": "The Send button of a notification banner's reply field: it types the text into the terminal and presses Return.", + "tab.untitled.browser": { "extractionState": "manual", "localizations": { - "ar": { + "en": { "stringUnit": { "state": "translated", - "value": "إرسال" + "value": "New Tab" } }, - "bs": { + "ja": { "stringUnit": { "state": "translated", - "value": "Pošalji" + "value": "新規タブ" } }, - "da": { + "ar": { "stringUnit": { "state": "translated", - "value": "Send" + "value": "علامة تبويب جديدة" } }, - "de": { + "bs": { "stringUnit": { - "state": "translated", - "value": "Senden" + "state": "needs_review", + "value": "Novi tab" } }, - "en": { + "da": { "stringUnit": { - "state": "translated", - "value": "Send" + "state": "needs_review", + "value": "Ny fane" } }, - "es": { + "de": { "stringUnit": { "state": "translated", - "value": "Enviar" + "value": "Neuer Tab" } }, - "fr": { + "es": { "stringUnit": { "state": "translated", - "value": "Envoyer" + "value": "Pestaña nueva" } }, - "it": { + "fr": { "stringUnit": { "state": "translated", - "value": "Invia" + "value": "Nouvel onglet" } }, - "ja": { + "it": { "stringUnit": { - "state": "translated", - "value": "送信" + "state": "needs_review", + "value": "Nuova scheda" } }, "km": { "stringUnit": { - "state": "translated", - "value": "ផ្ញើ" + "state": "needs_review", + "value": "ថេបថ្មី" } }, "ko": { "stringUnit": { "state": "translated", - "value": "보내기" + "value": "새 탭" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "Send" + "state": "needs_review", + "value": "Ny fane" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "Wyślij" + "state": "needs_review", + "value": "Nowa karta" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "Enviar" + "state": "needs_review", + "value": "Nova Aba" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "Отправить" + "state": "needs_review", + "value": "Новая вкладка" } }, "th": { "stringUnit": { - "state": "translated", - "value": "ส่ง" + "state": "needs_review", + "value": "แท็บใหม่" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "Gönder" + "state": "needs_review", + "value": "Yeni Sekme" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "Надіслати" + "state": "needs_review", + "value": "Нова вкладка" } }, "vi": { "stringUnit": { - "state": "translated", - "value": "Gửi" + "state": "needs_review", + "value": "Tab mới" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "发送" + "value": "新标签页" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "傳送" + "value": "新標籤頁" } } } }, - "notifications.reply.placeholder": { - "comment": "Placeholder of a notification banner's reply field; the text goes to the terminal that posted the notification.", + "notifications.reply.action": { + "comment": "A notification banner's Reply button (cmux notify --reply): it opens a text field whose text goes to the terminal.", "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "الرد على الطرفية…" + "value": "رد" } }, "bs": { "stringUnit": { "state": "translated", - "value": "Odgovori terminalu…" + "value": "Odgovori" } }, "da": { "stringUnit": { "state": "translated", - "value": "Svar terminalen…" + "value": "Svar" } }, "de": { "stringUnit": { "state": "translated", - "value": "Dem Terminal antworten…" + "value": "Antworten" } }, "en": { "stringUnit": { "state": "translated", - "value": "Reply to the terminal…" + "value": "Reply" } }, "es": { "stringUnit": { "state": "translated", - "value": "Responder a la terminal…" + "value": "Responder" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Répondre au terminal…" + "value": "Répondre" } }, "it": { "stringUnit": { "state": "translated", - "value": "Rispondi al terminale…" + "value": "Rispondi" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナルに返信…" + "value": "返信" } }, "km": { "stringUnit": { "state": "translated", - "value": "ឆ្លើយតបទៅស្ថានីយ…" + "value": "ឆ្លើយតប" } }, "ko": { "stringUnit": { "state": "translated", - "value": "터미널에 답장…" + "value": "답장" } }, "nb": { "stringUnit": { "state": "translated", - "value": "Svar terminalen…" + "value": "Svar" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Odpowiedz terminalowi…" + "value": "Odpowiedz" } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Responder ao terminal…" + "value": "Responder" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Ответить терминалу…" + "value": "Ответить" } }, "th": { "stringUnit": { "state": "translated", - "value": "ตอบกลับเทอร์มินัล…" + "value": "ตอบกลับ" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Terminale yanıt ver…" + "value": "Yanıtla" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Відповісти терміналу…" + "value": "Відповісти" } }, "vi": { "stringUnit": { "state": "translated", - "value": "Trả lời terminal…" + "value": "Trả lời" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "回复终端…" + "value": "回复" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "回覆終端機…" + "value": "回覆" } } } }, - "tab.untitled.terminal": { + "notifications.reply.send": { + "comment": "The Send button of a notification banner's reply field: it types the text into the terminal and presses Return.", "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "إرسال" } }, - "ja": { + "bs": { "stringUnit": { "state": "translated", - "value": "ターミナル" + "value": "Pošalji" } }, - "ar": { + "da": { "stringUnit": { "state": "translated", - "value": "طرفية" + "value": "Send" } }, - "bs": { + "de": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Senden" } }, - "da": { + "en": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Send" } }, - "de": { + "es": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Enviar" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Envoyer" } }, - "fr": { + "it": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Invia" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "送信" } }, "km": { "stringUnit": { - "state": "needs_review", - "value": "Terminal" + "state": "translated", + "value": "ផ្ញើ" } }, "ko": { "stringUnit": { "state": "translated", - "value": "터미널" + "value": "보내기" } }, "nb": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Send" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Wyślij" } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Enviar" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Отправить" } }, "th": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "ส่ง" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Gönder" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Термінал" + "value": "Надіслати" } }, "vi": { "stringUnit": { - "state": "needs_review", - "value": "Terminal" + "state": "translated", + "value": "Gửi" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "终端" + "value": "发送" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "終端" + "value": "傳送" } } } }, - "daemon.connecting": { + "notifications.reply.placeholder": { + "comment": "Placeholder of a notification banner's reply field; the text goes to the terminal that posted the notification.", "extractionState": "manual", "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connecting to cmux-tui…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux-tui に接続しています…" - } - }, "ar": { "stringUnit": { - "state": "needs_review", - "value": "جارٍ الاتصال بـ cmux-tui…" + "state": "translated", + "value": "الرد على الطرفية…" } }, "bs": { "stringUnit": { - "state": "needs_review", - "value": "Povezivanje s cmux-tui…" + "state": "translated", + "value": "Odgovori terminalu…" } }, "da": { "stringUnit": { - "state": "needs_review", - "value": "Opretter forbindelse til cmux-tui…" + "state": "translated", + "value": "Svar terminalen…" } }, "de": { "stringUnit": { - "state": "needs_review", - "value": "Verbindung zu cmux-tui wird hergestellt…" + "state": "translated", + "value": "Dem Terminal antworten…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reply to the terminal…" } }, "es": { "stringUnit": { - "state": "needs_review", - "value": "Conectando con cmux-tui…" + "state": "translated", + "value": "Responder a la terminal…" } }, "fr": { "stringUnit": { - "state": "needs_review", - "value": "Connexion à cmux-tui…" + "state": "translated", + "value": "Répondre au terminal…" } }, "it": { "stringUnit": { - "state": "needs_review", - "value": "Connessione a cmux-tui…" + "state": "translated", + "value": "Rispondi al terminale…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルに返信…" } }, "km": { "stringUnit": { - "state": "needs_review", - "value": "កំពុងភ្ជាប់ទៅ cmux-tui…" + "state": "translated", + "value": "ឆ្លើយតបទៅស្ថានីយ…" } }, "ko": { "stringUnit": { - "state": "needs_review", - "value": "cmux-tui에 연결하는 중…" + "state": "translated", + "value": "터미널에 답장…" } }, "nb": { "stringUnit": { - "state": "needs_review", - "value": "Kobler til cmux-tui…" + "state": "translated", + "value": "Svar terminalen…" } }, "pl": { "stringUnit": { - "state": "needs_review", - "value": "Łączenie z cmux-tui…" + "state": "translated", + "value": "Odpowiedz terminalowi…" } }, "pt-BR": { "stringUnit": { - "state": "needs_review", - "value": "Conectando ao cmux-tui…" + "state": "translated", + "value": "Responder ao terminal…" } }, "ru": { "stringUnit": { - "state": "needs_review", - "value": "Подключение к cmux-tui…" + "state": "translated", + "value": "Ответить терминалу…" } }, "th": { "stringUnit": { - "state": "needs_review", - "value": "กำลังเชื่อมต่อกับ cmux-tui…" + "state": "translated", + "value": "ตอบกลับเทอร์มินัล…" } }, "tr": { "stringUnit": { - "state": "needs_review", - "value": "cmux-tui'ye bağlanılıyor…" + "state": "translated", + "value": "Terminale yanıt ver…" } }, "uk": { "stringUnit": { - "state": "needs_review", - "value": "Підключення до cmux-tui…" + "state": "translated", + "value": "Відповісти терміналу…" } }, "vi": { "stringUnit": { - "state": "needs_review", - "value": "Đang kết nối với cmux-tui…" + "state": "translated", + "value": "Trả lời terminal…" } }, "zh-Hans": { "stringUnit": { - "state": "needs_review", - "value": "正在连接 cmux-tui…" + "state": "translated", + "value": "回复终端…" } }, "zh-Hant": { "stringUnit": { - "state": "needs_review", - "value": "正在連線 cmux-tui…" + "state": "translated", + "value": "回覆終端機…" } } } }, - "daemon.retrying": { + "tab.untitled.terminal": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Still retrying in the background." + "value": "Terminal" } }, "ja": { "stringUnit": { "state": "translated", - "value": "バックグラウンドで再試行を続けています。" + "value": "ターミナル" } }, "ar": { "stringUnit": { - "state": "needs_review", - "value": "لا تزال إعادة المحاولة جارية في الخلفية." + "state": "translated", + "value": "طرفية" } }, "bs": { "stringUnit": { - "state": "needs_review", - "value": "Ponovni pokušaji se nastavljaju u pozadini." + "state": "translated", + "value": "Terminal" } }, "da": { "stringUnit": { - "state": "needs_review", - "value": "Prøver stadig igen i baggrunden." + "state": "translated", + "value": "Terminal" } }, "de": { "stringUnit": { - "state": "needs_review", - "value": "Neue Versuche laufen im Hintergrund weiter." + "state": "translated", + "value": "Terminal" } }, "es": { "stringUnit": { - "state": "needs_review", - "value": "Se sigue reintentando en segundo plano." + "state": "translated", + "value": "Terminal" } }, "fr": { "stringUnit": { - "state": "needs_review", - "value": "Nouvelles tentatives en arrière-plan." + "state": "translated", + "value": "Terminal" } }, "it": { "stringUnit": { - "state": "needs_review", - "value": "Nuovi tentativi in corso in background." + "state": "translated", + "value": "Terminal" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "នៅតែព្យាយាមម្តងទៀតនៅផ្ទៃខាងក្រោយ។" + "value": "Terminal" } }, "ko": { "stringUnit": { - "state": "needs_review", - "value": "백그라운드에서 계속 재시도하는 중입니다." + "state": "translated", + "value": "터미널" } }, "nb": { "stringUnit": { - "state": "needs_review", - "value": "Prøver fortsatt på nytt i bakgrunnen." + "state": "translated", + "value": "Terminal" } }, "pl": { "stringUnit": { - "state": "needs_review", - "value": "Ponawianie trwa w tle." + "state": "translated", + "value": "Terminal" } }, "pt-BR": { "stringUnit": { - "state": "needs_review", - "value": "Ainda tentando novamente em segundo plano." + "state": "translated", + "value": "Terminal" } }, "ru": { "stringUnit": { - "state": "needs_review", - "value": "Повторные попытки продолжаются в фоне." + "state": "translated", + "value": "Terminal" } }, "th": { "stringUnit": { - "state": "needs_review", - "value": "ยังคงลองใหม่อยู่เบื้องหลัง" + "state": "translated", + "value": "Terminal" } }, "tr": { "stringUnit": { - "state": "needs_review", - "value": "Arka planda yeniden deneniyor." + "state": "translated", + "value": "Terminal" } }, "uk": { "stringUnit": { - "state": "needs_review", - "value": "Повторні спроби тривають у фоні." + "state": "translated", + "value": "Термінал" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Vẫn đang thử lại trong nền." + "value": "Terminal" } }, "zh-Hans": { "stringUnit": { - "state": "needs_review", - "value": "仍在后台重试。" + "state": "translated", + "value": "终端" } }, "zh-Hant": { "stringUnit": { - "state": "needs_review", - "value": "仍在背景重試。" + "state": "translated", + "value": "終端" } } } @@ -10383,6 +9596,138 @@ } } } + }, + "menu.help": { + "comment": "The Help menu in the menu bar; macOS adds its help search field to it.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Help" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساعدة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pomoć" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Hjælp" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hilfe" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ayuda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aide" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aiuto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヘルプ" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ជំនួយ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "도움말" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Hjelp" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pomoc" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ajuda" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Справка" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "วิธีใช้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yardım" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Довідка" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Trợ giúp" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "帮助" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輔助說明" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Strings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Strings.swift index 57bbd31dd482..d1e9105ce2c9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Strings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Strings.swift @@ -37,13 +37,3 @@ enum Strings { static var renameConfirm: String { String(localized: "rename.confirm", defaultValue: "Rename", bundle: .module) } static var cancel: String { String(localized: "common.cancel", defaultValue: "Cancel", bundle: .module) } } - -extension Strings { - static var daemonConnecting: String { String(localized: "daemon.connecting", defaultValue: "Connecting to cmux-tui…", bundle: .module) } - static var daemonServiceUnavailable: String { - String(localized: "daemon.serviceUnavailable", defaultValue: "cmux couldn’t start its background service.", bundle: .module) - } - static var daemonReinstall: String { String(localized: "daemon.reinstall", defaultValue: "Reinstall cmux to fix this.", bundle: .module) } - static var daemonCopyDetails: String { String(localized: "daemon.copyDetails", defaultValue: "Copy Details", bundle: .module) } - static var daemonRetrying: String { String(localized: "daemon.retrying", defaultValue: "Still retrying in the background.", bundle: .module) } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift index d0e050dcab64..f2e87b5416f4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift @@ -3,6 +3,7 @@ import CmuxNextAgentCursor import CmuxNextAgentPane import CmuxNextActions import CmuxNextCompat +import CmuxNextDaemonRecovery import CmuxNextHistory import CmuxNextBridge import CmuxNextBrowser @@ -194,10 +195,9 @@ final class WindowController: NSWindowController, NSWindowDelegate { if content == nil { showConnecting() } } - /// The connecting (or unavailable) state of the local daemon's first - /// connection, until a workspace can be shown. + /// The local daemon's first connection, connecting or unavailable, until a workspace shows. private func showConnecting() { - let view = connectingView ?? DaemonConnectingView(frame: .zero) + let view = connectingView ?? DaemonConnectingView(frame: .zero, recovery: services.daemon.recovery) if connectingView == nil { DebugTimings.markLaunch("connecting_view_shown") } connectingView = view root.show(view) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Client/AppsAvailability.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Client/AppsAvailability.swift index cab463761e28..362c20502d30 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Client/AppsAvailability.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Client/AppsAvailability.swift @@ -13,6 +13,14 @@ public nonisolated enum AppsUnavailableReason: Sendable, Hashable { /// The reason as shown (banner, app sections, action refusals). public var text: String { AppsStrings.unavailable(self) } + + /// cmux-tui itself is not connected (Retry Now and Restart cmux-tui can help). + public var daemonUnreachable: Bool { + switch self { + case .notConnected, .notReady: true + case .needsNewerDaemon, .turnedOff: false + } + } } /// Whether the supervisor answers. `epoch` changes on every (re)connect: diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/AppStoreModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/AppStoreModel.swift index 2bb378dfda75..8463260e28fa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/AppStoreModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/AppStoreModel.swift @@ -53,6 +53,10 @@ public final class AppStoreModel { /// Called after every page navigation (the titlebar arrows re-read /// ``canGoBack`` and ``canGoForward``). @ObservationIgnored public var onNavigate: (() -> Void)? + /// The App's recovery controls for the disconnected banner while + /// cmux-tui is not connected (Retry Now, Restart cmux-tui); read in the + /// banner's body, so it follows what they observe. Nil offers none. + @ObservationIgnored public var recovery: (() -> AppsRecovery)? @ObservationIgnored private let removalTimer = DemandTimer(owner: "AppStoreModel.removal") @ObservationIgnored private var removalWasEnabled = true /// How long a Remove can be undone before it is committed. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/Views/AppStoreRootView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/Views/AppStoreRootView.swift index b9f2d9618fa6..36c3e9b246b7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/Views/AppStoreRootView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/Views/AppStoreRootView.swift @@ -21,7 +21,9 @@ public struct AppStoreRootView: View { VStack(spacing: 0) { toolbar Rectangle().fill(colors.separator).frame(height: Borders.width(1)) - if let reason = model.client.unavailableReason { AppsDisconnectedBanner(reason: reason) } + if let reason = model.client.unavailableReason { + AppsDisconnectedBanner(reason: reason, recovery: reason.daemonUnreachable ? model.recovery?() : nil) + } switch model.tab { case .discover: AppDiscoverView(model: model) case .installed: AppInstalledView(model: model) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/Views/AppsDisconnectedBanner.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/Views/AppsDisconnectedBanner.swift index ca925aeb664e..5daba0904eba 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/Views/AppsDisconnectedBanner.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Store/Views/AppsDisconnectedBanner.swift @@ -3,9 +3,11 @@ import CmuxNextIcons import SwiftUI /// Why nothing can change: the supervisor is unreachable. Every control -/// below is disabled; nothing queues. +/// below is disabled; nothing queues. While cmux-tui itself cannot be reached +/// the banner carries the App's recovery controls (Retry Now, Restart cmux-tui). struct AppsDisconnectedBanner: View { let reason: AppsUnavailableReason + var recovery: AppsRecovery? @Environment(\.appSceneColors) private var colors var body: some View { @@ -14,12 +16,50 @@ struct AppsDisconnectedBanner: View { Text(AppsStrings.unavailable(reason)).font(Font(Typography.bodyEmphasized)) Text(AppsStrings.unavailableHelp).font(Font(Typography.caption)).foregroundStyle(colors.tertiary) Spacer() + if let recovery { + if let status = recovery.status { + Text(status).font(Font(Typography.caption)).foregroundStyle(colors.tertiary) + } + ForEach(recovery.actions) { action in + Button(action.title) { action.run() } + .controlSize(.small) + .disabled(!action.isEnabled) + .accessibilityIdentifier("appStore.disconnected.\(action.id)") + } + } } .foregroundStyle(colors.secondary) .padding(.horizontal, Metrics.space5) .padding(.vertical, Metrics.space2) .background(colors.hover) - .accessibilityElement(children: .combine) + .accessibilityElement(children: .contain) .accessibilityIdentifier("appStore.disconnected") } } + +/// The App's controls on the disconnected banner while cmux-tui cannot be +/// reached (``AppStoreModel/recovery``), and what a restart shows. +public nonisolated struct AppsRecovery { + public var actions: [AppsRecoveryAction] + public var status: String? + + public init(actions: [AppsRecoveryAction], status: String?) { + self.actions = actions + self.status = status + } +} + +/// One control on the disconnected banner. +public nonisolated struct AppsRecoveryAction: Identifiable { + public let id: String + public let title: String + public let isEnabled: Bool + public let run: @MainActor () -> Void + + public init(id: String, title: String, isEnabled: Bool, run: @escaping @MainActor () -> Void) { + self.id = id + self.title = title + self.isEnabled = isEnabled + self.run = run + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLaunchTimings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLaunchTimings.swift index 8a508d86c930..3c9f60f8c653 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLaunchTimings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLaunchTimings.swift @@ -28,7 +28,7 @@ public final class DaemonLaunchTimings: @unchecked Sendable { for (name, date) in early { receiver(name, date) } } - func mark(_ name: String) { + public func mark(_ name: String) { signposter.emitEvent("daemon", "\(name, privacy: .public)") let now = Date() let receiver = state.withLock { state -> (@Sendable (String, Date) -> Void)? in diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Build.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Build.swift index a06528847856..ae2d3ddf57da 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Build.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Build.swift @@ -28,4 +28,24 @@ extension DaemonLauncher { } return (session, stateDirectory) } + + /// Build commit of the bundled binary (`cmux 0.1.0 (; ghostty …)`). + public func bundledBuildCommit() async throws -> String? { + let result = try await ProcessRunner.run(executable: configuration.binary, arguments: ["--version"], + environment: nil, timeout: .seconds(5), clock: clock) + return Self.parseBuildCommit(String(decoding: result.stdout, as: UTF8.self)) + } + + static func parseBuildCommit(_ version: String) -> String? { + guard version.contains("(") else { return nil } + let commit = version.drop { $0 != "(" }.dropFirst().prefix { $0.isHexDigit } + return commit.count >= 7 ? String(commit) : nil + } + + /// True when the running daemon was built from a different commit than + /// the bundled binary, so the app should hand off with `restartDaemon`. + public func isStale(_ identity: DaemonIdentity) async -> Bool { + guard let running = identity.buildCommit, let bundled = try? await bundledBuildCommit() else { return false } + return running != bundled + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Replace.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Replace.swift new file mode 100644 index 000000000000..e0de6f6703dd --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Replace.swift @@ -0,0 +1,119 @@ +import CmuxNextCompat +import Foundation + +/// "Restart cmux-tui" (cx-jniyz.1): `server ensure --replace-unresponsive` +/// stops a session owner that accepts connections but never answers, then +/// ensures as usual (status `replaced`, or `running`/`started` when nothing +/// was stuck). The verb cannot tell a stuck owner from one still restoring, +/// so the app offers it only after a plain ensure failed the way a stuck owner +/// makes it fail (``EnsureOutcome``): the daemon's restore bound passed, or no +/// answer. The owner's terminal hosts survive a replace and the next owner +/// adopts them. +extension DaemonLauncher { + /// `server ensure --replace-unresponsive` refused (exit 3, `server.replace_*`): + /// the owner was not stopped. + public struct ReplaceRefusal: Error, Sendable, Equatable, CustomStringConvertible { + public var code: String + public var message: String? + public var description: String { "\(message ?? "replace refused") [\(code)]" } + } + + /// The longest a replace stays silent on stdout before its first line: + /// the 10 s identify probe, 5 s after SIGTERM and 5 s after SIGKILL, plus margin. + static let replaceSilence: Duration = .seconds(30) + + /// Runs the app's `server ensure` with `--replace-unresponsive` and + /// returns its result. Throws ``ReplaceRefusal`` when the owner was not + /// stopped, and the usual ensure errors when the new owner did not start. + @concurrent public func replaceUnresponsive() async throws -> EnsureResult { + async let reapGrace = supportsReapGrace() + let environment = await ensureEnvironment() + let progress = configuration.binaryIsBundled + let arguments = Self.ensureArguments(configuration, reapGrace: await reapGrace, progress: progress) + + ["--replace-unresponsive"] + // No progress line while the old owner is probed and stopped: the + // silence bound covers that, then restore progress bounds the rest. + let result = try await ProcessRunner.run( + executable: configuration.binary, + arguments: arguments, + environment: environment, + stdin: configuration.installKey?.payload, + timeout: Self.ensureRestoreCap + Self.replaceSilence, + progressIdle: progress ? Self.replaceSilence : nil, + clock: clock + ) + if result.status != 0, let failure = Self.ensureFailure(result), failure.code.hasPrefix("server.replace_") { + throw ReplaceRefusal(code: failure.code, message: failure.message) + } + let ensured = try Self.parseEnsure(result) + ensureOutcome.note(nil) + return ensured + } + + /// ``ensure()`` for the endpoint provider, recording how it ended in ``ensureOutcome``. + func ensureRecordingOutcome() async throws -> EnsureResult { + do { + let ensured = try await ensure() + ensureOutcome.note(nil) + return ensured + } catch { + // A cancelled ensure (its connection torn down, or shutdown) says nothing about the owner. + if !Task.isCancelled, !(error is CancellationError) { ensureOutcome.note(error) } + throw error + } + } + + /// The JSON error of a failed `server ensure` (the last JSON line on stderr). + static func ensureFailure(_ result: ProcessResult) -> EnsureFailure? { + let line = String(decoding: result.stderr, as: UTF8.self) + .split(whereSeparator: \.isNewline) + .last { $0.trimmingCharacters(in: .whitespaces).hasPrefix("{") } + return line.flatMap { try? JSONDecoder().decode(EnsureFailure.self, from: Data($0.utf8)) } + } + + /// True for a plain ensure failure a stuck owner causes: the restore bound + /// passed (`server.restore_timeout`), the ensure itself got no answer in + /// time, or no owner became ready (`server.unavailable`). + static func isStuckOwnerFailure(_ error: any Error) -> Bool { + switch error as? DaemonError { + case .sessionRestoreTimedOut?, .timedOut?: true + case .launchFailed(let detail)?: detail.contains("\"server.unavailable\"") + default: false + } + } +} + +/// Whether the launcher's last `server ensure` failed the way a stuck owner +/// makes it fail (``DaemonLauncher/isStuckOwnerFailure(_:)``), the gate of +/// "Restart cmux-tui". Shared by every copy of one launcher. +public final class EnsureOutcome: Sendable { + private struct State { + var ownerLooksStuck = false + var observer: (@MainActor @Sendable () -> Void)? + } + + private let state = Mutex(State()) + + public init() {} + + /// The last ensure failed as a stuck owner makes it fail; false after one that succeeded. + public var ownerLooksStuck: Bool { state.withLock { $0.ownerLooksStuck } } + + /// Calls `observer` on the main actor after each change of ``ownerLooksStuck``. + public func observe(_ observer: @escaping @MainActor @Sendable () -> Void) { + state.withLock { $0.observer = observer } + } + + /// Records how an ensure ended (nil: it succeeded). + func note(_ error: (any Error)?) { + let stuck = error.map(DaemonLauncher.isStuckOwnerFailure) ?? false + let observer = state.withLock { state -> (@MainActor @Sendable () -> Void)? in + guard state.ownerLooksStuck != stuck else { return nil } + state.ownerLooksStuck = stuck + return state.observer + } + guard let observer else { return } + // task-owner: one main-actor hop per change; the observer reads the current value + Task { @MainActor in observer() } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher.swift index 2d16fb7a5fa3..6820c21b77fa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher.swift @@ -99,6 +99,8 @@ public struct DaemonLauncher: Sendable { private let rememberedSocket: RememberedSocket /// Version handoff state (`handOffIfStale`). let handoff = DaemonHandoffState() + /// How the last endpoint `ensure` ended: the gate of Restart cmux-tui (DaemonLauncher+Replace). + public let ensureOutcome = EnsureOutcome() let clock: any Clock private let ensureTimeout: Duration /// Hard cap on one `server ensure`. The daemon bounds a restore itself @@ -417,30 +419,10 @@ public struct DaemonLauncher: Sendable { return DaemonEndpoint(socketPath: path) } } - return try await ensure().endpoint + return try await ensureRecordingOutcome().endpoint } } - /// Build commit of the bundled binary (`cmux 0.1.0 (; ghostty …)`). - public func bundledBuildCommit() async throws -> String? { - let result = try await ProcessRunner.run(executable: configuration.binary, arguments: ["--version"], - environment: nil, timeout: .seconds(5), clock: clock) - return Self.parseBuildCommit(String(decoding: result.stdout, as: UTF8.self)) - } - - static func parseBuildCommit(_ version: String) -> String? { - guard version.contains("(") else { return nil } - let commit = version.drop { $0 != "(" }.dropFirst().prefix { $0.isHexDigit } - return commit.count >= 7 ? String(commit) : nil - } - - /// True when the running daemon was built from a different commit than - /// the bundled binary, so the app should hand off with `restartDaemon`. - public func isStale(_ identity: DaemonIdentity) async -> Bool { - guard let running = identity.buildCommit, let bundled = try? await bundledBuildCommit() else { return false } - return running != bundled - } - /// Asks the running owner to exit (version handoff), then ensures a new /// one from this binary. PTY hosts survive and are adopted. public func restartDaemon(identity: DaemonIdentity, using connection: DaemonConnection) async throws -> EnsureResult { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/DaemonConnectingView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonConnectingView.swift similarity index 75% rename from Packages/macOS/CmuxNext/Sources/CmuxNextApp/DaemonConnectingView.swift rename to Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonConnectingView.swift index 4c3f12982acf..4b43d7c6d0a3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/DaemonConnectingView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonConnectingView.swift @@ -1,5 +1,6 @@ -import AppKit -import CmuxNextDaemon +public import AppKit +public import CmuxNextDaemon +public import CmuxNextDaemonService import CmuxNextDesign import CmuxNextWakeups @@ -10,12 +11,14 @@ import CmuxNextWakeups /// usable at once, cx-r650e); "Connecting to cmux-tui" shows after `launchTextDelay` only /// while not yet connected, so a fast launch replaces it first. Once the startup deadline /// passes, it shows the failure at once, in plain words, with the raw error -/// behind Copy Details for a bug report. -final class DaemonConnectingView: NSView { +/// behind Copy Details for a bug report, Retry Now and, for a stuck owner, +/// Restart cmux-tui (`DaemonRecoveryControls`, cx-jniyz.1). +public final class DaemonConnectingView: NSView { let mark = LaunchMarkView() private let titleLabel = ChromeLabel(labelWithString: "") private let detailLabel = NSTextField(wrappingLabelWithString: "") private let copyDetailsButton = ThemedBezelButton(title: "", target: nil, action: nil) + private let recoveryControls: DaemonRecoveryControls? /// The failure's raw text, for Copy Details. private var failureDetails = "" /// Copy Details' write (tests read it instead of the clipboard). @@ -28,13 +31,20 @@ final class DaemonConnectingView: NSView { /// The status line has waited out its delay (or a failure showed it). private var statusDue = false - convenience override init(frame: NSRect) { + public convenience override init(frame: NSRect) { self.init(frame: frame, clock: ContinuousClock()) } - /// - Parameter clock: Times the status delay (tests step it). - init(frame: NSRect, clock: any Clock) { + public convenience init(frame: NSRect, recovery: DaemonRecovery) { + self.init(frame: frame, clock: ContinuousClock(), recovery: recovery) + } + + /// - Parameters: + /// - clock: Times the status delay (tests step it). + /// - recovery: The local daemon's Retry Now and Restart; nil shows neither. + init(frame: NSRect, clock: any Clock, recovery: DaemonRecovery? = nil) { statusTimer = DemandTimer(owner: "launch.status", clock: clock) + recoveryControls = recovery.map(DaemonRecoveryControls.init(recovery:)) super.init(frame: frame) // Layer-backed, so the status line's fade sets its model alpha at once. wantsLayer = true @@ -45,12 +55,14 @@ final class DaemonConnectingView: NSView { detailLabel.maximumNumberOfLines = 4 detailLabel.isSelectable = true detailLabel.focusRingType = .none - copyDetailsButton.title = Strings.daemonCopyDetails + copyDetailsButton.title = DaemonRecoveryStrings.copyDetails copyDetailsButton.controlSize = .small copyDetailsButton.target = self copyDetailsButton.action = #selector(copyDetails) copyDetailsButton.isHidden = true - let stack = NSStackView(views: [mark, titleLabel, detailLabel, copyDetailsButton]) + var views: [NSView] = [mark, titleLabel, detailLabel] + if let recoveryControls { views.append(recoveryControls) } + let stack = NSStackView(views: views + [copyDetailsButton]) stack.orientation = .vertical stack.alignment = .centerX stack.spacing = Metrics.space2 @@ -68,9 +80,9 @@ final class DaemonConnectingView: NSView { } @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + public required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - override func viewDidChangeEffectiveAppearance() { + public override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() performWithTheme { titleLabel.textColor = Palette.textSecondary @@ -78,7 +90,7 @@ final class DaemonConnectingView: NSView { } } - override func viewDidMoveToWindow() { + public override func viewDidMoveToWindow() { super.viewDidMoveToWindow() viewDidChangeEffectiveAppearance() if window == nil { @@ -96,7 +108,7 @@ final class DaemonConnectingView: NSView { statusTimer.scheduleIfIdle(after: .seconds(MotionTunables.launchTextDelay.value)) { @MainActor [weak self] in guard let self else { return } statusDue = true - DebugTimings.markLaunch("connecting_status_shown") + DaemonLaunchTimings.shared.mark("connecting_status_shown") Motion.animate(.fadeIn, in: self.titleLabel) { self.titleLabel.animator().alphaValue = 1 } } } @@ -104,17 +116,18 @@ final class DaemonConnectingView: NSView { private func cancelStagger() { statusTimer.cancel() } - func apply(_ state: DaemonStartupState) { + public func apply(_ state: DaemonStartupState) { self.state = state switch state { case .connecting, .connected: // Connected: what is left is the first workspace (cx-b4sjc), not cmux-tui, so no status line (cx-r650e). if state == .connected { statusTimer.cancel(); statusDue = false } - titleLabel.stringValue = Strings.daemonConnecting + titleLabel.stringValue = DaemonRecoveryStrings.connecting titleLabel.alphaValue = statusDue ? 1 : 0 detailLabel.stringValue = "" detailLabel.isHidden = true copyDetailsButton.isHidden = true + recoveryControls?.isOffered = false staggerIfConnecting() case .unavailable(let error): cancelStagger() @@ -126,12 +139,13 @@ final class DaemonConnectingView: NSView { titleLabel.stringValue = RefusalStrings.daemonRestoreTimedOut detailLabel.stringValue = "" } else { - titleLabel.stringValue = Strings.daemonServiceUnavailable + titleLabel.stringValue = DaemonRecoveryStrings.serviceUnavailable detailLabel.stringValue = Self.plainReason(error) ?? "" } detailLabel.isHidden = detailLabel.stringValue.isEmpty failureDetails = String(describing: error) copyDetailsButton.isHidden = false + recoveryControls?.isOffered = true } setAccessibilityLabel([titleLabel.stringValue, detailLabel.stringValue].filter { !$0.isEmpty }.joined(separator: ". ")) } @@ -142,16 +156,16 @@ final class DaemonConnectingView: NSView { /// app serves the socket, a bad launch tag): Copy Details carries the specifics. private static func plainReason(_ error: DaemonError) -> String? { switch error { - case .binaryNotFound: Strings.daemonReinstall + case .binaryNotFound: DaemonRecoveryStrings.reinstall case .unsupportedProtocol: RefusalStrings.restartToUpdateDaemon case .missingCapabilities, .sessionRestoreTimedOut: RefusalStrings.describe(error) case .endpointBlocked(let reason): reason case .wrongApp, .invalidSessionName: nil - default: DaemonStartup.shared.isPermanent(error) ? nil : Strings.daemonRetrying + default: DaemonStartup.shared.isPermanent(error) ? nil : DaemonRecoveryStrings.retrying } } @objc private func copyDetails() { - copyToClipboard(failureDetails) + copyToClipboard([failureDetails, recoveryControls?.restartFailure].compactMap { $0 }.joined(separator: "\n")) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryControls.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryControls.swift new file mode 100644 index 000000000000..fa80a1986653 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryControls.swift @@ -0,0 +1,153 @@ +import AppKit +public import CmuxNextApps +import CmuxNextCompat +public import CmuxNextDaemonService +import CmuxNextDesign + +/// Strings of the launch window's connecting and failure screen and the +/// daemon recovery controls (Resources/Localizable.xcstrings). +enum DaemonRecoveryStrings { + static var connecting: String { String(localized: "daemon.connecting", defaultValue: "Connecting to cmux-tui…", bundle: .module) } + static var serviceUnavailable: String { + String(localized: "daemon.serviceUnavailable", defaultValue: "cmux couldn’t start its background service.", bundle: .module) + } + static var reinstall: String { String(localized: "daemon.reinstall", defaultValue: "Reinstall cmux to fix this.", bundle: .module) } + static var copyDetails: String { String(localized: "daemon.copyDetails", defaultValue: "Copy Details", bundle: .module) } + static var retrying: String { String(localized: "daemon.retrying", defaultValue: "Still retrying in the background.", bundle: .module) } + static var retryNow: String { String(localized: "daemon.recovery.retryNow", defaultValue: "Retry Now", bundle: .module) } + static var restart: String { String(localized: "daemon.recovery.restart", defaultValue: "Restart cmux-tui", bundle: .module) } + static var confirmTitle: String { String(localized: "daemon.recovery.confirmTitle", defaultValue: "Restart cmux-tui?", bundle: .module) } + static var confirmBody: String { + String(localized: "daemon.recovery.confirmBody", + defaultValue: "cmux-tui is not responding. Restarting it stops the stuck process and starts a new one. Terminals running in it may be lost.", + bundle: .module) + } + static var confirmButton: String { String(localized: "daemon.recovery.confirmButton", defaultValue: "Restart", bundle: .module) } + static var restarting: String { String(localized: "daemon.recovery.restarting", defaultValue: "Restarting cmux-tui…", bundle: .module) } + static var failed: String { + String(localized: "daemon.recovery.failed", defaultValue: "cmux-tui could not be restarted. Try again in a moment.", bundle: .module) + } +} + +/// Restart cmux-tui's question and the App Store banner's controls, one path +/// for both surfaces (cx-jniyz.1). The question is the cmux dialog every +/// destructive confirm uses, so the debug socket can answer it. +enum DaemonRestartPrompt { + static let confirmID = "restart" + static let identifier = "cmux.dialog.restartDaemon" + + /// Asks before Restart cmux-tui (terminals of a stuck owner may be lost), + /// then runs it. `window` scopes the question; none asks app-wide. + static func confirm(_ recovery: DaemonRecovery, in window: NSWindow?) { + let spec = CmuxDialogSpec(title: DaemonRecoveryStrings.confirmTitle, lines: [DaemonRecoveryStrings.confirmBody], + buttons: [.cancel(), CmuxDialogButton(id: confirmID, title: DaemonRecoveryStrings.confirmButton, role: .default)], + identifier: identifier) + CmuxDialogCenter.shared.present(spec, in: window.map(CmuxDialogScope.window) ?? .app) { answer in + guard answer.button == confirmID else { return } + // task-owner: one restart per confirm; DaemonRecovery ignores another while one runs + Task { await recovery.restart() } + } + } + + /// What the restart shows while it runs or after it failed; nil otherwise. + static func status(_ recovery: DaemonRecovery) -> String? { + if recovery.isRestarting { return DaemonRecoveryStrings.restarting } + return recovery.restartFailure == nil ? nil : DaemonRecoveryStrings.failed + } + + /// The App Store's disconnected banner controls (AppStoreModel.recovery). + static func appsRecovery(_ recovery: DaemonRecovery) -> AppsRecovery { + guard recovery.canRetry else { return AppsRecovery(actions: [], status: nil) } + var actions = [AppsRecoveryAction(id: "retryNow", title: DaemonRecoveryStrings.retryNow, isEnabled: !recovery.isRestarting) { + recovery.retryNow() + }] + if recovery.offersRestart || recovery.isRestarting { + actions.append(AppsRecoveryAction(id: "restart", title: DaemonRecoveryStrings.restart, isEnabled: !recovery.isRestarting) { + confirm(recovery, in: NSApp.keyWindow) + }) + } + return AppsRecovery(actions: actions, status: status(recovery)) + } +} + +extension DaemonRecovery { + /// The App Store's disconnected banner controls (AppStoreModel.recovery). + public var appsRecovery: AppsRecovery { DaemonRestartPrompt.appsRecovery(self) } +} + +/// Retry Now and Restart cmux-tui under the launch window's failure +/// (cx-jniyz.1). Restart shows only once a plain ensure failed as a stuck +/// owner makes it fail (`DaemonRecovery.offersRestart`). +final class DaemonRecoveryControls: NSStackView { + private let recovery: DaemonRecovery + private let statusLabel = NSTextField(wrappingLabelWithString: "") + private let retryButton = ThemedBezelButton(title: "", target: nil, action: nil) + private let restartButton = ThemedBezelButton(title: "", target: nil, action: nil) + private var observation: Task? + /// The window shows a failure: the controls show only then. + var isOffered = false { didSet { refresh() } } + + init(recovery: DaemonRecovery) { + self.recovery = recovery + super.init(frame: .zero) + orientation = .vertical + alignment = .centerX + spacing = Metrics.space2 + statusLabel.font = Typography.caption + statusLabel.alignment = .center + statusLabel.isSelectable = true + statusLabel.setAccessibilityIdentifier("daemon.recoveryStatus") + Self.configure(retryButton, title: DaemonRecoveryStrings.retryNow, identifier: "daemon.retryNow", target: self, action: #selector(retryNow)) + Self.configure(restartButton, title: DaemonRecoveryStrings.restart, identifier: "daemon.restart", target: self, action: #selector(confirmRestart)) + let row = NSStackView(views: [retryButton, restartButton]) + row.orientation = .horizontal + row.spacing = Metrics.space2 + addArrangedSubview(statusLabel) + addArrangedSubview(row) + refresh() + // task-owner: ends after this view is gone (weak self), at the recovery's next change + observation = Task { [weak self, recovery] in + for await _ in ObservationStream({ (recovery.offersRestart, recovery.isRestarting, recovery.restartFailure) }) { + guard let self else { return } + self.refresh() + } + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + private static func configure(_ button: NSButton, title: String, identifier: String, target: AnyObject, action: Selector) { + button.title = title + button.controlSize = .small + button.target = target + button.action = action + button.setAccessibilityIdentifier(identifier) + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + performWithTheme { statusLabel.textColor = Palette.textSecondary } + } + + /// The restart's failure, for Copy Details. + var restartFailure: String? { recovery.restartFailure } + + private func refresh() { + let restarting = recovery.isRestarting + isHidden = !isOffered || !recovery.canRetry + retryButton.isEnabled = !restarting + restartButton.isHidden = !(recovery.offersRestart || restarting) + restartButton.isEnabled = !restarting + statusLabel.stringValue = DaemonRestartPrompt.status(recovery) ?? "" + statusLabel.isHidden = statusLabel.stringValue.isEmpty + } + + @objc private func retryNow() { + recovery.retryNow() + } + + @objc private func confirmRestart() { + DaemonRestartPrompt.confirm(recovery, in: window) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/Resources/Localizable.xcstrings new file mode 100644 index 000000000000..4e99ba20865a --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/Resources/Localizable.xcstrings @@ -0,0 +1,1585 @@ +{ + "sourceLanguage": "en", + "strings": { + "daemon.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting to cmux-tui…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui に接続しています…" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "جارٍ الاتصال بـ cmux-tui…" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Povezivanje s cmux-tui…" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Opretter forbindelse til cmux-tui…" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Verbindung zu cmux-tui wird hergestellt…" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Conectando con cmux-tui…" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Connexion à cmux-tui…" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Connessione a cmux-tui…" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "កំពុងភ្ជាប់ទៅ cmux-tui…" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "cmux-tui에 연결하는 중…" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Kobler til cmux-tui…" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Łączenie z cmux-tui…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Conectando ao cmux-tui…" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Подключение к cmux-tui…" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "กำลังเชื่อมต่อกับ cmux-tui…" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "cmux-tui'ye bağlanılıyor…" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Підключення до cmux-tui…" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Đang kết nối với cmux-tui…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "正在连接 cmux-tui…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "正在連線 cmux-tui…" + } + } + } + }, + "daemon.serviceUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux couldn’t start its background service." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux はバックグラウンドサービスを起動できませんでした。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر على cmux تشغيل خدمته في الخلفية." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux nije mogao pokrenuti svoj pozadinski servis." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke starte sin baggrundstjeneste." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux konnte seinen Hintergrunddienst nicht starten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux no pudo iniciar su servicio en segundo plano." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n’a pas pu démarrer son service d’arrière-plan." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux non è riuscito ad avviare il suo servizio in background." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "cmux មិនអាចចាប់ផ្តើមសេវាផ្ទៃខាងក្រោយរបស់វាបានទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux가 백그라운드 서비스를 시작하지 못했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke starte bakgrunnstjenesten sin." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux nie mógł uruchomić swojej usługi w tle." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux não conseguiu iniciar seu serviço em segundo plano." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux не удалось запустить фоновую службу." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux ไม่สามารถเริ่มบริการเบื้องหลังได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux arka plan hizmetini başlatamadı." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "cmux не вдалося запустити фонову службу." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "cmux không thể khởi động dịch vụ nền." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 无法启动其后台服务。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 無法啟動其背景服務。" + } + } + } + }, + "daemon.reinstall": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reinstall cmux to fix this." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux を再インストールすると解決します。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أعد تثبيت cmux لإصلاح ذلك." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo instalirajte cmux da biste ovo popravili." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Geninstaller cmux for at løse det." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Installiere cmux neu, um das zu beheben." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reinstala cmux para solucionarlo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinstallez cmux pour corriger ce problème." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Reinstalla cmux per risolvere il problema." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ដំឡើង cmux ឡើងវិញ ដើម្បីដោះស្រាយបញ្ហានេះ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux를 다시 설치하면 해결됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Installer cmux på nytt for å løse dette." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zainstaluj cmux ponownie, aby to naprawić." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reinstale o cmux para corrigir isso." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переустановите cmux, чтобы это исправить." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ติดตั้ง cmux ใหม่เพื่อแก้ไขปัญหานี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bunu düzeltmek için cmux’u yeniden yükleyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перевстановіть cmux, щоб це виправити." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Hãy cài đặt lại cmux để khắc phục." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新安装 cmux 即可解决。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新安裝 cmux 即可解決。" + } + } + } + }, + "daemon.copyDetails": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Details" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細をコピー" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ التفاصيل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kopiraj detalje" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kopiér detaljer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Details kopieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar detalles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier les détails" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Copia dettagli" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ចម្លងព័ត៌មានលម្អិត" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세부 정보 복사" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kopier detaljer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kopiuj szczegóły" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Copiar detalhes" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скопировать подробности" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คัดลอกรายละเอียด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayrıntıları Kopyala" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Скопіювати подробиці" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Sao chép chi tiết" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制详细信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拷貝詳細資訊" + } + } + } + }, + "daemon.retrying": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Still retrying in the background." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バックグラウンドで再試行を続けています。" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "لا تزال إعادة المحاولة جارية في الخلفية." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Ponovni pokušaji se nastavljaju u pozadini." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Prøver stadig igen i baggrunden." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Neue Versuche laufen im Hintergrund weiter." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Se sigue reintentando en segundo plano." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Nouvelles tentatives en arrière-plan." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nuovi tentativi in corso in background." + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "នៅតែព្យាយាមម្តងទៀតនៅផ្ទៃខាងក្រោយ។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "백그라운드에서 계속 재시도하는 중입니다." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Prøver fortsatt på nytt i bakgrunnen." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ponawianie trwa w tle." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Ainda tentando novamente em segundo plano." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Повторные попытки продолжаются в фоне." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ยังคงลองใหม่อยู่เบื้องหลัง" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Arka planda yeniden deneniyor." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Повторні спроби тривають у фоні." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Vẫn đang thử lại trong nền." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "仍在后台重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "仍在背景重試。" + } + } + } + }, + "daemon.recovery.retryNow": { + "comment": "Button under the launch window's cmux-tui failure and on the App Store's disconnected banner: try to connect to cmux-tui now instead of at the next retry.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry Now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今すぐ再試行" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة المحاولة الآن" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokušaj ponovo sada" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Prøv igen nu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jetzt erneut versuchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reintentar ahora" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réessayer maintenant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riprova ora" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ព្យាយាមម្ដងទៀតឥឡូវនេះ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금 다시 시도" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Prøv igjen nå" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spróbuj teraz" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tentar agora" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повторить сейчас" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลองอีกครั้งตอนนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şimdi Yeniden Dene" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Повторити зараз" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thử lại ngay" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即重试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立即重試" + } + } + } + }, + "daemon.recovery.restart": { + "comment": "Button shown after cmux-tui stopped answering: stops the stuck cmux-tui process and starts a new one (asks first).", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart cmux-tui" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui を再起動" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تشغيل cmux-tui" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokreni cmux-tui" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart cmux-tui" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar cmux-tui" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer cmux-tui" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvia cmux-tui" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ចាប់ផ្ដើម cmux-tui ឡើងវិញ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui 재시작" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Start cmux-tui på nytt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom ponownie cmux-tui" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar o cmux-tui" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить cmux-tui" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีสตาร์ต cmux-tui" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui'yi Yeniden Başlat" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перезапустити cmux-tui" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Khởi động lại cmux-tui" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启 cmux-tui" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動 cmux-tui" + } + } + } + }, + "daemon.recovery.confirmTitle": { + "comment": "Title of the question before Restart cmux-tui.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart cmux-tui?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui を再起動しますか?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "هل تريد إعادة تشغيل cmux-tui؟" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokrenuti cmux-tui?" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart cmux-tui?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui neu starten?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Reiniciar cmux-tui?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer cmux-tui ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavviare cmux-tui?" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ចាប់ផ្ដើម cmux-tui ឡើងវិញ?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui를 재시작할까요?" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Starte cmux-tui på nytt?" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchomić ponownie cmux-tui?" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar o cmux-tui?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить cmux-tui?" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีสตาร์ต cmux-tui ไหม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui yeniden başlatılsın mı?" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перезапустити cmux-tui?" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Khởi động lại cmux-tui?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "要重启 cmux-tui 吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要重新啟動 cmux-tui 嗎?" + } + } + } + }, + "daemon.recovery.confirmBody": { + "comment": "Body of the question before Restart cmux-tui. Terminals usually survive, but a stuck process may lose them, so the text says they may be lost.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui is not responding. Restarting it stops the stuck process and starts a new one. Terminals running in it may be lost." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui が応答していません。再起動すると、停止したプロセスを終了して新しいプロセスを開始します。その中で実行中のターミナルが失われる可能性があります。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui لا يستجيب. تؤدي إعادة تشغيله إلى إيقاف العملية العالقة وبدء عملية جديدة. قد تُفقد الطرفيات التي تعمل فيه." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui ne odgovara. Ponovnim pokretanjem zaustavlja se zaglavljeni proces i pokreće novi. Terminali koji rade u njemu mogu biti izgubljeni." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui svarer ikke. En genstart stopper den fastlåste proces og starter en ny. Terminaler, der kører i den, kan gå tabt." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui reagiert nicht. Ein Neustart beendet den hängenden Prozess und startet einen neuen. Darin laufende Terminals können verloren gehen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui no responde. Al reiniciarlo se detiene el proceso bloqueado y se inicia uno nuevo. Los terminales que se ejecutan en él podrían perderse." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui ne répond pas. Le redémarrer arrête le processus bloqué et en lance un nouveau. Les terminaux qui y sont ouverts pourraient être perdus." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui non risponde. Il riavvio arresta il processo bloccato e ne avvia uno nuovo. I terminali in esecuzione al suo interno potrebbero andare persi." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui មិនឆ្លើយតបទេ។ ការចាប់ផ្ដើមវាឡើងវិញនឹងបញ្ឈប់ដំណើរការដែលជាប់គាំង ហើយចាប់ផ្ដើមដំណើរការថ្មីមួយ។ ទែមីណាល់ដែលកំពុងដំណើរការនៅក្នុងវាអាចបាត់បង់។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui가 응답하지 않습니다. 재시작하면 멈춘 프로세스를 중지하고 새 프로세스를 시작합니다. 그 안에서 실행 중인 터미널이 사라질 수 있습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui svarer ikke. En omstart stopper den fastlåste prosessen og starter en ny. Terminaler som kjører i den, kan gå tapt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui nie odpowiada. Ponowne uruchomienie zatrzyma zawieszony proces i uruchomi nowy. Terminale działające w nim mogą zostać utracone." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux-tui não está respondendo. Reiniciá-lo encerra o processo travado e inicia um novo. Os terminais em execução nele podem ser perdidos." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui не отвечает. Перезапуск остановит зависший процесс и запустит новый. Терминалы, работающие в нём, могут быть потеряны." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui ไม่ตอบสนอง การรีสตาร์ตจะหยุดโปรเซสที่ค้างและเริ่มโปรเซสใหม่ เทอร์มินัลที่ทำงานอยู่ในนั้นอาจสูญหาย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui yanıt vermiyor. Yeniden başlatmak takılan süreci durdurur ve yenisini başlatır. İçinde çalışan terminaller kaybolabilir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui не відповідає. Перезапуск зупинить завислий процес і запустить новий. Термінали, що працюють у ньому, можуть бути втрачені." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui không phản hồi. Khởi động lại sẽ dừng tiến trình bị treo và chạy một tiến trình mới. Các terminal đang chạy trong đó có thể bị mất." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui 没有响应。重启会停止卡住的进程并启动一个新进程。其中运行的终端可能会丢失。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui 沒有回應。重新啟動會停止卡住的程序並啟動新的程序。其中執行中的終端機可能會遺失。" + } + } + } + }, + "daemon.recovery.confirmButton": { + "comment": "Confirm button of the Restart cmux-tui question.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再起動" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التشغيل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo pokreni" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstart" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvia" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ចាប់ផ្ដើមឡើងវិញ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재시작" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Start på nytt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchom ponownie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีสตาร์ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden Başlat" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перезапустити" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Khởi động lại" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動" + } + } + } + }, + "daemon.recovery.restarting": { + "comment": "Status line while cmux-tui is being restarted (up to about 20 seconds).", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restarting cmux-tui…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui を再起動しています…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ إعادة تشغيل cmux-tui…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovno pokretanje cmux-tui…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genstarter cmux-tui…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui wird neu gestartet…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciando cmux-tui…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrage de cmux-tui…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riavvio di cmux-tui…" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងចាប់ផ្ដើម cmux-tui ឡើងវិញ…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui 재시작 중…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Starter cmux-tui på nytt…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ponowne uruchamianie cmux-tui…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reiniciando o cmux-tui…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапуск cmux-tui…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังรีสตาร์ต cmux-tui…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui yeniden başlatılıyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перезапуск cmux-tui…" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang khởi động lại cmux-tui…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在重启 cmux-tui…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在重新啟動 cmux-tui…" + } + } + } + }, + "daemon.recovery.failed": { + "comment": "Status line after Restart cmux-tui did not work; Retry Now stays next to it.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui could not be restarted. Try again in a moment." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui を再起動できませんでした。しばらくしてからもう一度お試しください。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّرت إعادة تشغيل cmux-tui. حاول مرة أخرى بعد قليل." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui nije moguće ponovo pokrenuti. Pokušajte ponovo za trenutak." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui kunne ikke genstartes. Prøv igen om lidt." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui konnte nicht neu gestartet werden. Versuche es gleich noch einmal." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo reiniciar cmux-tui. Inténtalo de nuevo en un momento." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de redémarrer cmux-tui. Réessayez dans un instant." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile riavviare cmux-tui. Riprova tra un momento." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនអាចចាប់ផ្ដើម cmux-tui ឡើងវិញបានទេ។ សូមព្យាយាមម្ដងទៀតបន្តិចទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui를 재시작할 수 없습니다. 잠시 후 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke starte cmux-tui på nytt. Prøv igjen om litt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się ponownie uruchomić cmux-tui. Spróbuj ponownie za chwilę." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível reiniciar o cmux-tui. Tente novamente em instantes." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось перезапустить cmux-tui. Повторите попытку чуть позже." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถรีสตาร์ต cmux-tui ได้ โปรดลองอีกครั้งในอีกสักครู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux-tui yeniden başlatılamadı. Birazdan tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося перезапустити cmux-tui. Спробуйте ще раз за мить." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Không thể khởi động lại cmux-tui. Hãy thử lại sau giây lát." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法重启 cmux-tui。请稍后再试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法重新啟動 cmux-tui。請稍後再試。" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonRecovery.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonRecovery.swift new file mode 100644 index 000000000000..7e6190aeebf2 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonRecovery.swift @@ -0,0 +1,69 @@ +import CmuxNextDaemon +import CmuxNextWakeups +public import Observation +import os + +/// The local daemon's recovery while it cannot be reached (cx-jniyz.1): the +/// launch window and the App Store's disconnected banner offer Retry Now and, +/// only after a plain `server ensure` failed as a stuck owner makes it fail +/// (``offersRestart``), Restart cmux-tui. Remote machines have no launcher, +/// so they offer neither. +@MainActor +@Observable +public final class DaemonRecovery { + /// The last `server ensure` failed as a stuck owner makes it fail (the + /// restore bound passed, or no answer): `--replace-unresponsive` cannot + /// tell a stuck owner from a slow restore, so it is offered only then. + public private(set) var offersRestart = false + /// A Restart runs (up to 20 s of probe and stop, then the new owner's start). + public private(set) var isRestarting = false + /// Why the last Restart did not work (the refusal code or error, for Copy + /// Details); nil after Retry Now or a Restart that worked. + public private(set) var restartFailure: String? + @ObservationIgnored private var launcher: DaemonLauncher? + @ObservationIgnored private var wake: RetryWake? + @ObservationIgnored private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "app.daemon") + + public init() {} + + /// True once the local daemon's launcher is known (`DaemonService.start(launch:)`). + public var canRetry: Bool { wake != nil } + + /// Follows `launcher`'s ensure outcomes; `wake` is the service's retry wake. + func attach(_ launcher: DaemonLauncher, wake: RetryWake) { + self.launcher = launcher + self.wake = wake + let outcome = launcher.ensureOutcome + outcome.observe { [weak self] in + guard let self else { return } + offersRestart = outcome.ownerLooksStuck + // An ensure that worked (the owner recovered by itself) ends an earlier Restart's failure. + if !offersRestart { restartFailure = nil } + } + offersRestart = outcome.ownerLooksStuck + } + + /// Tries to connect now instead of at the next backoff step. + public func retryNow() { + restartFailure = nil + wake?.fire() + } + + /// Stops the unresponsive owner and starts a new one (`server ensure + /// --replace-unresponsive`), then tries to connect at once. SIGTERM + /// leaves the terminal hosts running and the new owner adopts them. + public func restart() async { + guard let launcher, !isRestarting else { return } + isRestarting = true + restartFailure = nil + do { + let result = try await launcher.replaceUnresponsive() + logger.notice("cmux-tui restart: \(result.status, privacy: .public), pid \(result.pid)") + } catch { + restartFailure = String(describing: error) + logger.error("cmux-tui restart failed: \(String(describing: error), privacy: .public)") + } + isRestarting = false + wake?.fire() + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+Endpoint.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+Endpoint.swift index 942399c0437e..bb83a49400ca 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+Endpoint.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+Endpoint.swift @@ -1,4 +1,5 @@ public import CmuxNextDaemon +import Foundation /// The daemon's capabilities and its socket, for callers that open their own connection. extension DaemonService { @@ -27,3 +28,13 @@ extension DaemonService { return CreatingTerminalInput(terminalID: id, connection: connection, targetKnown: targetKnown) } } + +/// Which machine this service reaches. +extension DaemonService { + public var isLocal: Bool { machineID == "local" } + + /// Directory new terminals start in: the Mac's home for the local + /// daemon; nil (the machine's own default) on a Cloud machine, where a + /// Mac path does not exist. + public var defaultCwd: String? { isLocal ? NSHomeDirectory() : nil } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift index 17e2d5dd199f..6c4bc74243d0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift @@ -29,6 +29,8 @@ public final class DaemonService { public let store = DaemonStore() /// Set while an administrator turned this machine's feature off: no endpoint, no re-attach. public let policyBlock = PolicyBlock() + /// Retry Now and Restart cmux-tui while the local daemon cannot be reached (cx-jniyz.1). + public let recovery = DaemonRecovery() public private(set) var connection: DaemonConnection? public private(set) var windowState: WindowStateStore? /// The current (or last) daemon's identity. The store owns it and @@ -76,13 +78,6 @@ public final class DaemonService { ) { _ in wake.fire() } } - public var isLocal: Bool { machineID == "local" } - - /// Directory new terminals start in: the Mac's home for the local - /// daemon; nil (the machine's own default) on a Cloud machine, where a - /// Mac path does not exist. - public var defaultCwd: String? { isLocal ? NSHomeDirectory() : nil } - /// How the first connection is going (window connecting state, control /// errors). Becomes `.unavailable` after `startupDeadline` or on an /// incompatible daemon; retrying continues in the background. @@ -134,6 +129,7 @@ public final class DaemonService { } } endpointProvider = launcher.endpointProvider + recovery.attach(launcher, wake: retryWake) // Before the first connection the retry loop has no socket watch of its own (the connection installs one // after its first handshake): watch the socket the last launch used and its ready marker, so a daemon // that finishes a long restore or handoff wakes a loop past its timed budget (cx-4bd8m). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/RestingPlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/RestingPlacement.swift new file mode 100644 index 000000000000..3ac5d6e9ef0f --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/RestingPlacement.swift @@ -0,0 +1,12 @@ +public import AppKit + +/// A view a layout spring shows away from where it will rest: a pane host +/// while the strip camera scrolls, or a split pane sliding in. Window-relative +/// chrome inside it (a tab strip clearing the traffic lights) measures against +/// the rest, so it lays out once, at its final place, and then moves rigidly +/// with the view instead of laying out again on every frame of the motion. +@MainActor +public protocol RestingPlacement: NSView { + /// Where the view will rest minus where it shows now, in its superview's points. + var restOffset: CGVector { get } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift index aadd5e491fa2..c4e9952eff5f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift @@ -16,7 +16,7 @@ import QuartzCore /// `OverlayPlane`, not in this view, so they draw above content that is a /// child window (Chromium pages); the root keeps them on this view's /// displayed frame. -final class PaneHostView: NSView { +final class PaneHostView: NSView, RestingPlacement { let pane: PaneID let content: NSView let chrome = PaneOverlayView() @@ -59,6 +59,12 @@ final class PaneHostView: NSView { /// What of the host shows while a spring rect reveals it (a split's arriving pane, a zooming /// one; superview coordinates); nil, its whole frame. Panes under it are clipped by this. var revealRect: CGRect? + /// Where the host will rest minus where it shows now (`RestingPlacement`): its spring rect's + /// target and the strip camera's target, against the presented frame. Zero at rest; coming + /// to rest tells the content, whose window-relative chrome held its motion layout. + var restOffset: CGVector = .zero { + didSet { if restOffset == .zero, oldValue != .zero, window != nil { reporter?.paneFrameInWindowDidChange() } } + } var isDocked = false { didSet { if isDocked != oldValue { applyDockFill() } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Motion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Motion.swift index 8d21a7abf36b..72e1a94ccc3a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Motion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Motion.swift @@ -42,7 +42,7 @@ extension ScreenContentView { /// New dock columns' panes still growing in from their screen edge (their frames). var growingDocks: [AnimatedFrame] { PaneArrival.growing(arriving, frames: paneFrames, in: geometry, docks: newDockColumns) } func applyPresentation() { - let strip = stripShift + let strip = stripShift, restStrip = geometry.viewShift(offset: isUserScrolling ? scroll.value : scroll.target) let uncovered = uncoveredRect, clip = PaneArrival.stripClip(geometry, docks: growingDocks) var clips: [(host: PaneHostView, scrolls: Bool, revealing: CGRect?)] = [] for (pane, frame) in paneFrames { @@ -61,6 +61,9 @@ extension ScreenContentView { ? SplitMotion.visible(frame.rect, padding: host.padding).offsetBy(dx: shift.dx, dy: shift.dy) : nil let placed = (zoomed ?? held ?? (arriving.contains(pane) ? PaneArrival.presentation(shown: frame.rect, target: frame.targetRect) : frame.rect)) .offsetBy(dx: shift.dx, dy: shift.dy) + // Before the frame moves: chrome that re-checks itself on the move reads the rest. + let rest = frame.targetRect.offsetBy(dx: scrolls ? restStrip : 0, dy: shift.dy) + host.restOffset = CGVector(dx: rest.minX - placed.minX, dy: rest.minY - placed.minY) if host.frame != placed { host.frame = placed } (host.alphaValue, host.isDocked, host.revealRect) = (frame.alpha.value, !scrolls, revealing) clips.append((host, scrolls, revealing)) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarPerfCounters.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarPerfCounters.swift index f64f613166d1..4ff99a35440f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarPerfCounters.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarPerfCounters.swift @@ -33,12 +33,22 @@ nonisolated enum SidebarPerfCounters { } private static let counts = Mutex<[Counter: Int]>([:]) + /// Per-caller counts of the costly counters ("list_reloads@SidebarView+Observation.swift:90"), + /// so a breach names its cause (perf-gate.py prints them with the breach). + private static let sources = Mutex<[String: Int]>([:]) static func add(_ counter: Counter, _ amount: Int = 1) { guard amount != 0 else { return } counts.withLock { $0[counter, default: 0] += amount } } + /// `add`, also counted under its caller. + static func add(_ counter: Counter, from file: StaticString, line: UInt) { + add(counter) + let name = "\(file)".split(separator: "/").last.map(String.init) ?? "\(file)" + sources.withLock { $0["\(counter.rawValue)@\(name):\(line)", default: 0] += 1 } + } + /// Runs `body` and adds its duration to `main_ns`. static func timed(_ body: () throws -> T) rethrows -> T { let start = DispatchTime.now().uptimeNanoseconds @@ -52,14 +62,19 @@ nonisolated enum SidebarPerfCounters { defer { add(.mirrorNanoseconds, Int(DispatchTime.now().uptimeNanoseconds &- start)) } return try body() } - /// Every counter by name; `reset` zeroes them after reading. + /// Every counter by name, then each caller count ("@:"); `reset` zeroes them after reading. static func report(reset: Bool) -> [String: Int] { - counts.withLock { values in + var report = counts.withLock { values in var report: [String: Int] = [:] for counter in Counter.allCases { report[counter.rawValue] = values[counter] ?? 0 } if reset { values = [:] } return report } + sources.withLock { values in + report.merge(values) { counter, _ in counter } + if reset { values = [:] } + } + return report } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarRegionView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarRegionView.swift index 0c0221beabd1..53800bbc9367 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarRegionView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarRegionView.swift @@ -64,6 +64,18 @@ final class SidebarRegionView: NSView { private(set) var layoutResult = SidebarRegionLayout.empty private(set) var content: Content? + /// The selected top item (`SidebarModel.selectedItem`), drawn active. Kept out of `Content`: + /// a selection change repaints the old and new item only, never lays the region out + /// (plans/cmux-next/perf-speed-of-light.md I6). + var activeItem: LayoutItemID? { + didSet { + guard oldValue != activeItem else { return } + for id in [oldValue, activeItem].compactMap({ $0 }) { + guard let view = itemViews[id] else { continue } + view.configure(activeInfo(view.info, id), style: view.style) + } + } + } private(set) var itemViews: [LayoutItemID: SidebarItemRowView] = [:] private(set) var headerViews: [LayoutSectionID: SidebarSectionHeaderView] = [:] /// The drag in progress (R77) and the order the region shows while it @@ -255,7 +267,7 @@ final class SidebarRegionView: NSView { case .chip: .chip default: section.look == .builtIn ? .builtIn : .list } - view.configure(content.infos[id] ?? .fallback(for: item), style: style) + view.configure(activeInfo(content.infos[id] ?? .fallback(for: item), id), style: style) place(view, row.frame, new: new) if id == SidebarListBlocks.gapID { view.alphaValue = 0 } // a drag's gap: the slot the others make way for } @@ -388,6 +400,13 @@ final class SidebarRegionView: NSView { contextMenuProvider?(emptySpaceTarget) } + /// `info` drawn active when `id` is the selected item. + private func activeInfo(_ info: SidebarItemInfo, _ id: LayoutItemID) -> SidebarItemInfo { + var info = info + info.isActive = id == activeItem + return info + } + /// The item view for `id` (tests, hover cards). func itemView(_ id: LayoutItemID) -> SidebarItemRowView? { itemViews[id] } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Layout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Layout.swift index 23b0fca1e593..30e82c5cf0f8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Layout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Layout.swift @@ -6,8 +6,8 @@ import QuartzCore extension SidebarListViewComponents { // MARK: - Reload /// Recomputes layout from the model and animates rows to their frames. - func reload(animated: Bool, adopt: Bool = false) { - SidebarPerfCounters.add(.listReloads) + func reload(animated: Bool, adopt: Bool = false, file: StaticString = #fileID, line: UInt = #line) { + SidebarPerfCounters.add(.listReloads, from: file, line: line) // No drag holds the rows: this reload shows every held patch (the model has them). if !holdsRowPatches { rowHold = SidebarRowHold() } workspaces = [:] @@ -217,6 +217,7 @@ extension SidebarListViewComponents { view.configure(item, favicon: item.iconAddress.flatMap { SidebarModel.itemFavicon?($0) }, row: row) case let (.block(id), view as SidebarBlockRowView): guard let content = blockContent(id, includeGap: true) else { return } + view.region.activeItem = activeTopItem if springsRows, animated { view.springShow(content, width: bounds.width) } else { view.show(content, width: bounds.width, animated: animated) } case let (.tab(_, tabID), view as SidebarTabRowView): guard let tab = tabs[tabID] else { return } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift index eb389f64582a..928df5bb2978 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift @@ -28,6 +28,14 @@ final class SidebarListView: NSView { // A held drag hit-tests a geometry built at its begin: new blocks rebuild it at the next move. didSet { if blocks != oldValue { drag?.docGeometry = nil } } } + /// The selected top item, drawn active in its block (`SidebarRegionView.activeItem`): a selection + /// change repaints the old and new item only, with no list reload (perf-speed-of-light I6). + var activeTopItem: LayoutItemID? { + didSet { + guard oldValue != activeTopItem else { return } + for view in rowViews.values { (view as? SidebarBlockRowView)?.region.activeItem = activeTopItem } + } + } /// A dropped drag whose move is on the way: the list keeps showing its drop (the rows or tile at the /// slot, the source closed) until the model or the blocks change, so the drop settles with no fly-back /// and no pop when the move lands; a refusal lets it go and the rows spring back. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+Observation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+Observation.swift index 7687e31f49cb..e4d5ef7fbafa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+Observation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+Observation.swift @@ -65,7 +65,7 @@ extension SidebarViewComponents { || lastState?.titlebarHeight != state.titlebarHeight let profileChanged = lastState?.activeProfile != state.activeProfile let profilesChanged = lastState?.profiles != state.profiles || profileChanged - || lastState?.layout != state.layout || lastState?.itemInfo != state.itemInfo || Self.bandsDrawSelectionChange(from: lastState?.selected, to: state.selected) + || lastState?.layout != state.layout || lastState?.itemInfo != state.itemInfo || lastState?.transientTopItems != state.transientTopItems || lastState?.collapsedSections != state.collapsedSections || lastState?.look != state.look || lastState?.drawsLines != state.drawsLines || lastState?.preferences != state.preferences || lastState?.suppressedApps != state.suppressedApps @@ -79,6 +79,9 @@ extension SidebarViewComponents { if lastState?.preferences.minimalMode != state.preferences.minimalMode || lastState?.itemInfo != state.itemInfo { setChromeRevealed(isChromeRevealed) } + // A selected top item (a workspace's tile, a band item) repaints only itself and the one before it; + // before any reload below, so a block it configures draws the new selection. + if lastState?.selected != state.selected { showActiveTopItem() } // A document change (a drop) moves the list's rows and its blocks together: the blocks update now, not at // the next layout pass, so the list reloads once with both (ptr8: a settle on the rows alone showed the // stale blocks for a frame: the dragged tile back in Pinned, the rows bumped, the card aimed 24 pt off). @@ -95,13 +98,6 @@ extension SidebarViewComponents { if chromeChanged || profilesChanged { bandsDirty = true; needsLayout = true } lastState = state } - /// The bands draw only a top item's selection; a move between two list rows leaves them as they are. - static func bandsDrawSelectionChange(from old: SidebarItem?, to new: SidebarItem?) -> Bool { - guard old != new else { return false } - if case .topItem? = old { return true } - if case .topItem? = new { return true } - return false - } /// Whether a sections change animates its rows. Provisional rows swap in place. static func animatesReload(from old: [SidebarSection]?, to new: [SidebarSection]) -> Bool { !((old ?? []) + new).contains(where: \.hasProvisionalRows) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+Sections.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+Sections.swift index 209d3278c34a..c1804e757dac 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+Sections.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+Sections.swift @@ -84,10 +84,9 @@ extension SidebarViewComponents { let isFooter = { (section: LayoutSection) in section.id == SidebarLayoutDocument.bottomSectionID } let look = SidebarSectionTunables.currentLook let metrics = SidebarRegionMetrics.standard - // The one selection marks its top item active (its glyph, accessibility). + // The selection is not content: `showActiveTopItem` marks its item active (its glyph, accessibility). var selectedInfos = model.resolvedItemInfo for transient in model.transientTopItems { selectedInfos[transient.item.id] = transient.info } - for id in selectedInfos.keys { selectedInfos[id]?.isActive = model.selectedItem == .topItem(id) } func content(_ sections: [LayoutSection]) -> SidebarRegionView.Content { SidebarRegionView.Content(sections: sections.map(titled), infos: selectedInfos, collapsed: model.collapsedLayoutSections, look: look, metrics: metrics, drawsLines: Borders.drawsLines, appHeights: appHeights(sections, width: width), @@ -123,6 +122,14 @@ extension SidebarViewComponents { releaseAppSections(shown: shownAbove + footer) } + /// Draws the one selection on the top item it names, in every band and list block: the old and new + /// item repaint, nothing lays out (a workspace switch to a workspace with a tile, I6). + func showActiveTopItem() { + let active: LayoutItemID? = if case let .topItem(id)? = model.selectedItem { id } else { nil } + for region in [aboveRegion, belowRegion, footerRegion] { region.activeItem = active } + list.activeTopItem = active + } + /// App sections no band shows any more release their content; one that /// moved between bands is still shown and keeps it. private func releaseAppSections(shown: [LayoutSection]) { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+WindowControls.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+WindowControls.swift index 44deb3ef7652..c3b947b05ddf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+WindowControls.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+WindowControls.swift @@ -3,8 +3,12 @@ import CmuxNextDesign // A strip under the window's traffic lights (minimal titlebar, sidebar // hidden, the top-left pane) starts its tabs after them. Only strips whose -// frame overlaps the traffic lights get the inset, so it follows sidebar -// show and hide, splits, column scrolling and fullscreen. +// shown part overlaps the traffic lights get the inset, so it follows sidebar +// show and hide, splits, column scrolling and fullscreen. A strip in a pane on +// a layout spring (the strip camera scrolling, a split sliding in) measures +// where its pane will rest (`RestingPlacement`): it lays out once, for its +// final place, and its tabs then move with the column, never pinned to the +// traffic lights while the column slides under them or under the sidebar. extension TabStripView { /// Re-checks the traffic lights after the strip moved in its window /// without a resize (the App calls it when the pane's window frame @@ -19,15 +23,47 @@ extension TabStripView { func computeWindowControlsInset() -> CGFloat { guard let window else { return 0 } let host = window as? TitlebarAccessoryHosting - return Self.windowControlsInset(strip: convert(bounds, to: nil), lights: WindowTitlebar.trafficLightsFrame(in: window), accessory: host?.titlebarAccessoryFrame, - padding: metrics.stripHorizontalPadding) + let (strip, shown, moving) = restingWindowRect() + let resting = Self.windowControlsInset(strip: strip, shown: shown, lights: WindowTitlebar.trafficLightsFrame(in: window), + accessory: host?.titlebarAccessoryFrame, padding: metrics.stripHorizontalPadding) + // On the way, the larger of the inset it shows and the one it rests with: a column leaving + // the traffic lights keeps its tabs clear of them until it rests (off screen by then), and + // one arriving clears them from the start (while still off screen), so neither jumps in view. + return moving ? max(resting, windowControlsInset) : resting + } + + /// The strip's frame in window coordinates where its pane will rest, the window area its + /// ancestors above that pane show (their bounds, which do not move with it), and whether the + /// pane is still on its way. + private func restingWindowRect() -> (strip: CGRect, shown: CGRect, moving: Bool) { + var placement: (any RestingPlacement)? + var ancestor = superview + while let view = ancestor, placement == nil { + placement = view as? RestingPlacement + ancestor = view.superview + } + var strip = convert(bounds, to: nil) + let moving = placement.map { $0.restOffset != .zero } ?? false + if let placement, let parent = placement.superview { + let now = parent.convert(placement.frame, to: nil) + let rest = parent.convert(placement.frame.offsetBy(dx: placement.restOffset.dx, dy: placement.restOffset.dy), to: nil) + strip = strip.offsetBy(dx: rest.minX - now.minX, dy: rest.minY - now.minY) + } + var shown = CGRect.infinite + while let view = ancestor { + shown = shown.intersection(view.convert(view.bounds, to: nil)) + ancestor = view.superview + } + return (strip, shown, moving) } /// Leading points a strip at `strip` (window coordinates) keeps clear of - /// the traffic lights and the window's titlebar accessory (pure). - static func windowControlsInset(strip: CGRect, lights: CGRect?, accessory: CGRect?, padding: CGFloat) -> CGFloat { + /// the traffic lights and the window's titlebar accessory where they lie + /// over its `shown` part (pure). + static func windowControlsInset(strip: CGRect, shown: CGRect, lights: CGRect?, accessory: CGRect?, padding: CGFloat) -> CGFloat { + let visible = strip.intersection(shown) let obstacles = [lights, accessory].compactMap { $0 }.filter { frame in - strip.minY < frame.maxY && strip.maxY > frame.minY && strip.minX < frame.maxX && strip.maxX > frame.minX + !visible.isNull && visible.minY < frame.maxY && visible.maxY > frame.minY && visible.minX < frame.maxX && visible.maxX > frame.minX } guard let right = obstacles.map(\.maxX).max() else { return 0 } let clear = right + Metrics.space3 - strip.minX - padding diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonRestartTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonRestartTests.swift new file mode 100644 index 000000000000..8eb38cd67fdb --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonRestartTests.swift @@ -0,0 +1,215 @@ +import AppKit +@testable import CmuxNextApp +import CmuxNextDaemon +@testable import CmuxNextDaemonRecovery +import CmuxNextDesign +import Foundation +import Testing + +/// cx-jniyz.1: a cmux-tui owner that accepts connections but never answers leaves the launch +/// window on "cmux-tui is unavailable" with nothing to press. The window offers Retry Now, and, +/// once a plain `server ensure` failed the way a stuck owner makes it fail (the daemon's restore +/// bound passed, or no answer), Restart cmux-tui: after a confirm it runs the same `server ensure` +/// with `--replace-unresponsive`, then tries to connect again. A refusal says so and leaves Retry. +/// +/// The app runs its real startup path against a stand-in `cmux-tui` script (never a real daemon, +/// never a signal): `server status` finds no owner, `server ensure` fails as the test says, and +/// `server ensure --replace-unresponsive` answers `replaced` or a refusal. +@MainActor @Suite(.serialized, .timeLimit(.minutes(2))) +struct DaemonRestartTests { + /// The stand-in's state: its call log and the files that pick its answers. + private struct StandIn { + let root: URL + var log: URL { root.appendingPathComponent("calls.log") } + /// Present: a plain ensure fails with `server.restore_timeout` (else `server.spawn_failed`). + var stuck: URL { root.appendingPathComponent("stuck") } + /// Present: `--replace-unresponsive` is refused (`server.replace_still_running`). + var refuse: URL { root.appendingPathComponent("refuse") } + + /// Each call's arguments, in call order. + func calls() -> [[String]] { + let text = (try? String(contentsOf: log, encoding: .utf8)) ?? "" + return text.split(separator: "\n").map { $0.split(separator: "\u{1F}", omittingEmptySubsequences: false).map(String.init) } + } + + func ensures() -> [[String]] { calls().filter { $0.contains("ensure") } } + + func mark(_ file: URL) { FileManager.default.createFile(atPath: file.path, contents: Data()) } + } + + private struct Launched { + let services: AppServices + let standIn: StandIn + + var view: DaemonConnectingView? { services.windows.controllers.first?.connectingView } + + func stop() { + CmuxDialogCenter.shared.dismissAll() + services.daemon.shutdownConnection() + for controller in services.windows.controllers { controller.window?.close() } + try? FileManager.default.removeItem(at: standIn.root) + } + } + + private static func standInScript(_ standIn: StandIn, session: String) -> String { + """ + #!/bin/sh + line="" + for arg in "$@"; do line="$line$(printf '\\037')$arg"; done + printf '%s\\n' "${line#?}" >> '\(standIn.log.path)' + case " $* " in + *" --version "*) echo 'cmux-tui 0.1.0'; exit 0 ;; + *" server status "*) exit 1 ;; + *" --replace-unresponsive "*) + if [ -e '\(standIn.refuse.path)' ]; then + echo '{"code":"server.replace_still_running","message":"cannot replace the unresponsive session owner","details":{},"retryable":false}' >&2 + exit 3 + fi + echo '{"status":"replaced","session":"\(session)","socket":"\(standIn.root.path)/gone.sock","pid":4242,"generation":"g-2","replaced_pid":4141,"message":"replaced"}' + exit 0 ;; + *" server ensure "*) + if [ -e '\(standIn.stuck.path)' ]; then + echo '{"code":"server.restore_timeout","message":"the session owner is still restoring","details":{},"retryable":false}' >&2 + else + echo '{"code":"server.spawn_failed","message":"could not start the session owner","details":{},"retryable":false}' >&2 + fi + exit 3 ;; + esac + exit 64 + """ + } + + /// The app's startup path (launch window, startup loop, first attempt) on the stand-in. + private static func launch(stuck: Bool, refuse: Bool = false) throws -> Launched { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("cn-restart-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let standIn = StandIn(root: root) + if stuck { standIn.mark(standIn.stuck) } + if refuse { standIn.mark(standIn.refuse) } + let session = "cn-restart-\(UUID().uuidString.prefix(8).lowercased())" + let binary = root.appendingPathComponent("cmux-tui") + try standInScript(standIn, session: session).write(to: binary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) + // As the app's launcher: bundled (so `--restore-progress`) with page origins (`--ws-loopback`). + let launcher = DaemonLauncher( + configuration: .init(binary: binary, session: session, stateDirectory: root.appendingPathComponent("state"), + binaryIsBundled: true, webSocketPageOrigins: ["http://127.0.0.1:1"]), + environment: { ["HOME": NSHomeDirectory(), "PATH": "/usr/bin:/bin"] }) + _ = NSApplication.shared + let environment = AppEnvironment.current([:]) + let services = AppServices(environment: environment) + services.windows.ordersWindowsIn = false + let prestart = DaemonPrestart(launcher: launcher, configuration: DaemonConnection.Configuration(terminalEnvironment: nil)) + services.daemon.start(launch: environment.launch, terminalEnvironment: [:], terminalEnvironmentProvider: { [:] }, + prestart: prestart) + services.windows.restoreWhenLoaded() + return Launched(services: services, standIn: standIn) + } + + /// Polls until `done` holds or `limit` passes (as `RapidWorkspaceCreateTests` does). + private static func wait(within limit: Duration, until done: () -> Bool) async { + let deadline = ContinuousClock.now + limit + while !done(), ContinuousClock.now < deadline, !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(100)) + } + } + + private static func control(_ identifier: String, in view: NSView?, as type: T.Type) -> T? { + guard let view else { return nil } + if view.accessibilityIdentifier() == identifier, let match = view as? T { return match } + for subview in view.subviews { + if let match = control(identifier, in: subview, as: type) { return match } + } + return nil + } + + private static func shown(_ identifier: String, in view: NSView?) -> NSButton? { + guard let button = control(identifier, in: view, as: NSButton.self), !button.isHiddenOrHasHiddenAncestor else { return nil } + return button + } + + /// The button's own target and action (a click outside an ordered-in window may never dispatch). + private static func press(_ button: NSButton) throws { + let action = try #require(button.action) + _ = (try #require(button.target) as AnyObject).perform(action, with: button) + } + + /// Waits for the launch window's failure, so a fixture that never got there fails here. + private static func unavailableView(_ launched: Launched) async throws -> DaemonConnectingView { + await wait(within: .seconds(40)) { launched.view?.state.isUnavailable == true } + let view = try #require(launched.view, "the launch window never showed the connecting view") + try #require(view.state.isUnavailable, "the launch window never showed the failure") + return view + } + + /// Presses Restart cmux-tui and confirms the question it asks. + private static func restartAndConfirm(_ view: DaemonConnectingView) async throws { + try press(try #require(shown("daemon.restart", in: view))) + await wait(within: .seconds(5)) { CmuxDialogCenter.shared.records.contains { $0.spec.identifier == "cmux.dialog.restartDaemon" } } + let question = try #require(CmuxDialogCenter.shared.records.last { $0.spec.identifier == "cmux.dialog.restartDaemon" }) + #expect(!question.spec.lines.isEmpty) + #expect(question.spec.cancelButton != nil) + #expect(CmuxDialogCenter.shared.press(question.id, button: "restart")) + } + + @Test func restartIsOfferedOnlyAfterAPlainEnsureFailedAndReplacesThenConnectsAgain() async throws { + let launched = try Self.launch(stuck: false) + defer { launched.stop() } + let standIn = launched.standIn + let view = try await Self.unavailableView(launched) + + // A start failure that is not a stuck owner: Retry Now only. + let retry = Self.shown("daemon.retryNow", in: view) + #expect(retry != nil) + #expect(Self.shown("daemon.restart", in: view) == nil) + #expect(standIn.ensures().allSatisfy { !$0.contains("--replace-unresponsive") }) + + // The owner is stuck now: Retry Now asks again, and the failure offers Restart. + standIn.mark(standIn.stuck) + let before = standIn.ensures().count + try Self.press(try #require(retry)) + await Self.wait(within: .seconds(10)) { Self.shown("daemon.restart", in: view) != nil } + #expect(standIn.ensures().count > before) + #expect(Self.shown("daemon.restart", in: view) != nil) + + try await Self.restartAndConfirm(view) + await Self.wait(within: .seconds(10)) { + let ensures = standIn.ensures() + guard let replace = ensures.firstIndex(where: { $0.contains("--replace-unresponsive") }) else { return false } + return ensures[(replace + 1)...].contains { !$0.contains("--replace-unresponsive") } + } + let ensures = standIn.ensures() + let replaceIndex = try #require(ensures.firstIndex { $0.contains("--replace-unresponsive") }) + let plain = try #require(ensures[..:". + out["sources"] = {**(layout.get("sources") or {}), **{k: v for k, v in sidebar.items() if "@" in k}} return out def settle(self): @@ -385,8 +385,10 @@ def switch_steps(self, targets, scenario): shown = landed.get("shown") if shown != target: check(False, f"switch to {target} landed (shown {shown})", scenario) + reloads = {k: v for k, v in measured["sources"].items() if k.startswith("list_reloads@")} rows.append({"to": target, "latency_ms": round(latency, 1), - **{k: v for k, v in measured.items() if k != "sources"}}) + **{k: v for k, v in measured.items() if k != "sources"}, + **({"list_reload_callers": reloads} if reloads else {})}) patches += measured.get("selection_patches", 0) breaches = self.unknown(over(measured, BUDGET["switch"]), scenario) if breaches: diff --git a/scripts/cmux-next/strip-scroll-check.py b/scripts/cmux-next/strip-scroll-check.py new file mode 100755 index 000000000000..ba93bdd5a432 --- /dev/null +++ b/scripts/cmux-next/strip-scroll-check.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Check a strip scroll recording (motion-record.py --surface strip) frame by frame. + +For each scenario folder (left-1, left-2, right-1, right-2) it reads the +window frames and reports: + + camera the strip camera's per-frame dx, measured on the terminal prompt + line (best overlap of the two frames' text), in window points. + When the frames share no prompt text, the shift most pill edges + agree on (listed as camera_from_pills; a pill check there only + compares pills against each other). + pills every tab pill edge in the tab bar that was on screen in the + previous frame must have moved by the camera dx (within 1 px at + 2x, 0.5 pt). A pinned or creeping pill fails. Edges at the strip's + clip edges are skipped (they are cut, not moved). + blank every column whose prompt text is on screen (at least 20 pt of + it) must show text there. Columns are placed from the scenario's + layout.json and the summed camera dx; the text's extent in the + column comes from a frame where the column rests on screen. + start the first frame the camera moved, counted from the frame the key + landed in (timing.json; without it the recorder's 0.15 s lead). + +Usage: strip-scroll-check.py DIR [--scale 2] [--strip-left 240] [--json OUT] +DIR is the recorder's strip folder (DIR/left-1/frame-00000.jpg ...). +Exit status 1 when any check fails. +""" +import argparse, json, os, sys + +import numpy as np +from PIL import Image + +parser = argparse.ArgumentParser() +parser.add_argument("dir") +parser.add_argument("--scale", type=float, default=2.0, help="pixels per window point in the frames") +parser.add_argument("--strip-left", type=float, default=None, help="the strip's left edge in window points (default: the layout's main pane)") +parser.add_argument("--json", default=None) +opts = parser.parse_args() +S = opts.scale +SCENARIOS = ["left-1", "left-2", "right-1", "right-2", "hidden-left-1", "hidden-right-1"] + + +def frames_of(directory): + names = sorted(n for n in os.listdir(directory) if n.startswith("frame-") and n.endswith(".jpg")) + return [np.asarray(Image.open(os.path.join(directory, n)).convert("L"), dtype=np.int16) for n in names] + + +def load_json(path): + try: + with open(path) as handle: + return json.load(handle) + except (OSError, ValueError): + return None + + +def panes_of(layout): + windows = ((layout or {}).get("pane_chrome") or {}).get("windows") or [] + return windows[0] if windows else {} + + +def camera_dx(a, b, x0, x1, y0, y1, search): + """Integer pixel shift d that best maps frame a's prompt text onto frame b's (b[x] ~ a[x - d]): + the highest overlap (IoU) of the two frames' text masks. None when they share too little text.""" + mask_a, mask_b = a[y0:y1, x0:x1] > 120, b[y0:y1, x0:x1] > 120 + if not mask_a.any() or not mask_b.any(): + return None + width = x1 - x0 + best, best_d = 0.0, None + for d in range(-search, search + 1): + if abs(d) >= width: + continue + if d >= 0: + sa, sb = mask_a[:, :width - d], mask_b[:, d:] + else: + sa, sb = mask_a[:, -d:], mask_b[:, :width + d] + inter = (sa & sb).sum() + if inter < 60: + continue + iou = inter / (sa | sb).sum() + if iou > best: + best, best_d = iou, d + return best_d if best >= 0.5 else None + + +def pill_vote(prev, cur, x0, x1, sign, limit): + """The shift most on-screen pill edges agree on (within 2 px), from (lefts, rights) of two + frames; only shifts in the scroll's direction (`sign`) up to `limit` pixels.""" + best, best_d = 0, None + for kind in (0, 1): + for p in prev[kind]: + for c in cur[kind]: + d = c - p + if d * sign <= 0 or abs(d) > limit: + continue + votes = sum(1 for k in (0, 1) for e in cur[k] if x0 + 3 < e < x1 - 3 + and any(abs(e - d - q) <= 2 for q in prev[k])) + if votes > best: + best, best_d = votes, d + return best_d + + +def pill_edges(frame, y, x0, x1): + """Left and right edges (pixels) of the light pill runs on row y between x0 and x1.""" + row = frame[y, :] + on = row > 48 + lefts, rights = [], [] + x = x0 + while x < x1: + if on[x]: + start = x + while x < x1 and on[x]: + x += 1 + if x - start >= 16: + lefts.append(start) + rights.append(x) + x += 1 + return lefts, rights + + +def check(directory, layout, lead, sign): + frames = frames_of(directory) + times = (load_json(os.path.join(directory, "frames.json")) or {}).get("times") or [] + timing = load_json(os.path.join(directory, "timing.json")) + window = panes_of(layout) + main = window.get("main_pane") or [240, 0, 860, 720] + left_pt = opts.strip_left if opts.strip_left is not None else main[0] + right_pt = main[0] + main[2] + x0, x1 = int(round(left_pt * S)) + 2, int(round(right_pt * S)) - 2 + h = frames[0].shape[0] + # The prompt line (first cell row) and the pill row (above the tab titles). + prompt_y0, prompt_y1 = int(38 * S), int(58 * S) + pill_y = int(7 * S) + cams = [0.0] + sources = ["rest"] + for i in range(1, len(frames)): + d = camera_dx(frames[i - 1], frames[i], x0, x1, prompt_y0, prompt_y1, int(450 * S)) + source = "text" + if d is None: + # No shared prompt text (it is cut by the strip's edge): the pills' consensus. + d = pill_vote(pill_edges(frames[i - 1], pill_y, x0, x1), pill_edges(frames[i], pill_y, x0, x1), x0, x1, + sign, int(450 * S)) + source = "pills" + if d is None: + d, source = 0, "none" + cams.append(d / S) + sources.append(source) + # Pills: every on-screen edge moves by the camera dx. + pill_fail = [] + for i in range(1, len(frames)): + dx_px = cams[i] * S + prev_l, prev_r = pill_edges(frames[i - 1], pill_y, x0, x1) + cur_l, cur_r = pill_edges(frames[i], pill_y, x0, x1) + worst = 0.0 + for kind, cur, prev in (("left", cur_l, prev_l), ("right", cur_r, prev_r)): + for e in cur: + if e <= x0 + 3 or e >= x1 - 3: + continue + expected = e - dx_px + if expected <= x0 + 20 or expected >= x1 - 20: + continue # cut, off screen or a sliver (under the 16 px run floor) in the previous frame + miss = min((abs(p - expected) for p in prev), default=9999) + worst = max(worst, miss / S) + if worst > 0.5 + 1e-6: + pill_fail.append((i, round(worst, 1))) + # Blank: place columns from the layout and the summed camera. Each column's prompt text + # extent comes from a frame where it rests fully on screen (the first or the last). + panes = window.get("panes") or [] + cells = [p["cell"] for p in panes if p.get("cell")] + total = sum(cams) + blank = [] + unchecked = 0 + checked = 0 + for cell in cells: + extent = None + for frame, offset in ((frames[0], 0.0), (frames[-1], total)): + cx = cell[0] + offset + if cx >= left_pt - 6 and cx + cell[2] <= right_pt + 6: + band = frame[prompt_y0:prompt_y1, max(0, int(cx * S)):int((cx + cell[2]) * S)] + xs = np.nonzero((band > 120).any(axis=0))[0] + if len(xs): + extent = (xs[0] / S + max(0, -cx), xs[-1] / S + max(0, -cx)) + break + if extent is None: + continue + for i, frame in enumerate(frames): + cx = cell[0] + sum(cams[1:i + 1]) + a = max(cx + extent[0], left_pt + 1) + b = min(cx + extent[1], right_pt - 1) + if b <= a: + continue + if b - a < 20: + unchecked += 1 + continue + checked += 1 + region = frame[prompt_y0:prompt_y1, int(a * S):int(b * S)] + if (region > 120).sum() < 6: + blank.append((i, round(cell[0], 0))) + # Start: the first frame the camera moved, from the key's frame. + if timing and times: + key_t = timing["trigger_sent"] - timing["record_reply"] + key_t_late = timing["trigger_reply"] - timing["record_reply"] + assumed = False + else: + key_t, key_t_late, assumed = lead, lead + 0.01, True + key_frame = next((i for i, t in enumerate(times) if t >= key_t), None) + moved = next((i for i in range(1, len(cams)) if abs(cams[i]) >= 0.5), None) + return { + "frames": len(frames), + "camera_pt": [round(c, 1) for c in cams], + "camera_total_pt": round(total, 1), + "camera_from_pills": [i for i, src in enumerate(sources) if src == "pills"], + "camera_unmeasured": [i for i, src in enumerate(sources) if src == "none"], + "pill_fail_frames": pill_fail, + "blank_frames": blank, + "blank_checked": checked, + "blank_unchecked": unchecked, + "key_time_s": [round(key_t, 4), round(key_t_late, 4)], + "key_time_assumed": assumed, + "key_frame": key_frame, + "camera_first_frame": moved, + "frames_to_motion": None if moved is None or key_frame is None else moved - key_frame, + "ok": not pill_fail and not blank and moved is not None and key_frame is not None and moved - key_frame <= 2, + }, total + + +def shifted(layout, total): + """The next scenario's start layout when it has none: this one's moved by the camera.""" + window = json.loads(json.dumps(panes_of(layout))) + for pane in window.get("panes") or []: + if pane.get("cell"): + pane["cell"][0] += total + return {"pane_chrome": {"windows": [window]}} + + +report = {} +fallback = load_json(os.path.join(opts.dir, "layout.json")) +failed = False +for name in SCENARIOS: + directory = os.path.join(opts.dir, name) + if not os.path.isdir(directory): + continue + layout = load_json(os.path.join(directory, "layout.json")) or fallback + result, total = check(directory, layout, lead=0.15, sign=1 if "left" in name else -1) + result["layout"] = "scenario" if os.path.exists(os.path.join(directory, "layout.json")) else "chained" + report[name] = result + fallback = shifted(layout, total) + failed = failed or not result["ok"] + print(f"{name}: ok={result['ok']} frames={result['frames']} camera_total={result['camera_total_pt']}pt " + f"start={result['frames_to_motion']} frames after the key (key frame {result['key_frame']}" + f"{', assumed 0.15 s lead' if result['key_time_assumed'] else ''}) " + f"pill_fail={result['pill_fail_frames']} camera_from_pills={result['camera_from_pills']} unmeasured={result['camera_unmeasured']} blank={result['blank_frames']} " + f"(prompt checks {result['blank_checked']}, unchecked {result['blank_unchecked']}, layout {result['layout']})") + print(f" camera dx/frame (pt): {result['camera_pt']}") +if opts.json: + with open(opts.json, "w") as handle: + json.dump(report, handle, indent=1) +sys.exit(1 if failed else 0)