diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index 68d76a033860..4d63596e991a 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -464,12 +464,13 @@ let packageTargets: [Target] = uiLayerPreviewTargets + [ // supplies `AccountsServices`. .target( name: "CmuxNextAccounts", - dependencies: ["CmuxNextCloud", "CmuxNextCodeRouter", "CmuxNextDesign", "CmuxNextIcons", "CmuxNextWakeups", .product(name: "CmuxAgentBrands", package: "CmuxAgentBrands"), + dependencies: ["CmuxNextCloud", "CmuxNextCodeRouter", "CmuxNextDesign", "CmuxNextIcons", "CmuxNextSettings", "CmuxNextWakeups", .product(name: "CmuxAgentBrands", package: "CmuxAgentBrands"), .product(name: "CMUXAuthCore", package: "CMUXAuthCore")], resources: [ .process("Localizable.xcstrings"), // BillingStrings (table Billing): moved from the App with Billing/ in B1. .process("Resources/Billing.xcstrings"), + .process("Resources/GitHubConnection.xcstrings"), ], swiftSettings: uiSwiftSettings ), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAccounts/GitHubConnections+Page.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAccounts/GitHubConnections+Page.swift new file mode 100644 index 000000000000..64b7b34f989b --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAccounts/GitHubConnections+Page.swift @@ -0,0 +1,46 @@ +public import CmuxNextSettings +import Foundation + +extension GitHubConnections { + public var pageState: JSONValue { + let note: String + if scopeIsSignedOut { note = Self.signIn } + else if error { note = Self.failed } + else if pending { note = Self.pendingText } + else if connected { note = Self.status("active") } + else if !ready, !busy { note = Self.unavailable } + else { note = Self.detail } + return [ + "outcome": .string(scopeIsSignedOut ? "needs-action" : error ? "failed" : (pending || busy) ? "pending" : connected ? "connected" : "needs-action"), + "title": "GitHub", "detail": .string(note), "busy": .bool(busy), + "connect": .string(Self.connect), "disconnect": .string(Self.disconnect), + "confirm": .string(Self.confirm), "cancel": .string(Self.cancel), + "refresh": .string(Self.refresh), "canConnect": .bool(canConnect), + "connections": .array(currentConnections.map { row in + ["id": row["id"] ?? .null, + "account": row["account"]?["name"] ?? "GitHub", + "status": .string(Self.status(row["status"]?.stringValue)), + "canDisconnect": .bool(canDisconnect(row)), + "permissions": row["scopes_granted"] ?? .array([])] + }), + ] + } +} + +private extension GitHubConnections { + static var connect: String { String(localized: "github.connect", defaultValue: "Connect GitHub", table: "GitHubConnection", bundle: .module) } + static var disconnect: String { String(localized: "github.disconnect", defaultValue: "Disconnect", table: "GitHubConnection", bundle: .module) } + static var confirm: String { String(localized: "github.confirm", defaultValue: "Disconnect GitHub? Agents will lose access through this connection.", table: "GitHubConnection", bundle: .module) } + static var cancel: String { String(localized: "github.cancel", defaultValue: "Cancel", table: "GitHubConnection", bundle: .module) } + static var refresh: String { String(localized: "github.refresh", defaultValue: "Refresh", table: "GitHubConnection", bundle: .module) } + static var detail: String { String(localized: "github.detail", defaultValue: "Connect the cmux GitHub App. Choose repositories and review permissions in GitHub.", table: "GitHubConnection", bundle: .module) } + static var signIn: String { String(localized: "github.signIn", defaultValue: "Sign in to cmux to connect GitHub.", table: "GitHubConnection", bundle: .module) } + static var unavailable: String { String(localized: "github.unavailable", defaultValue: "GitHub connections are unavailable on this deployment.", table: "GitHubConnection", bundle: .module) } + static var pendingText: String { String(localized: "github.pending", defaultValue: "Finish connecting in your browser, then refresh.", table: "GitHubConnection", bundle: .module) } + static var failed: String { String(localized: "github.failed", defaultValue: "GitHub could not be updated. Refresh to try again.", table: "GitHubConnection", bundle: .module) } + static func status(_ status: String?) -> String { + if status == "active" { return String(localized: "github.connected", defaultValue: "Connected", table: "GitHubConnection", bundle: .module) } + if status == "pending" { return pendingText } + return String(localized: "github.reconnect", defaultValue: "Connect GitHub again to restore access.", table: "GitHubConnection", bundle: .module) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAccounts/GitHubConnections.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAccounts/GitHubConnections.swift new file mode 100644 index 000000000000..70f5bd016e66 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAccounts/GitHubConnections.swift @@ -0,0 +1,153 @@ +public import CmuxNextSettings +import CryptoKit +public import Foundation +import Observation + +/// The GitHub App connection for the account and team selected in Settings. +/// Calls the existing owner API as the person; never widens the app credential relay. +@MainActor @Observable +public final class GitHubConnections { + public struct Scope: Hashable, Sendable { + public let user: String + public let team: String? + public init(user: String, team: String?) { self.user = user; self.team = team } + } + public typealias Call = @MainActor (Scope, String, JSONValue, String?) async throws -> JSONValue + @ObservationIgnored private let scope: () -> Scope? + @ObservationIgnored private let call: Call + @ObservationIgnored private let open: (URL) -> Void + @ObservationIgnored private let now: () -> Date + @ObservationIgnored private var connectKeys: [Scope: String] = [:] + private var loadedScope: Scope? + private var workingScope: Scope? + private var generation = 0 + @ObservationIgnored private var authorizations: [Scope: (id: String, url: URL, expires: Date)] = [:] + private(set) var connections: [JSONValue] = [] + private(set) var configured = false + private var errorScope: Scope? + public var error: Bool { scope() != nil && errorScope == scope() } + public var pending: Bool { currentConnections.contains { $0["status"]?.stringValue == "pending" } || scope().map { authorizations[$0] != nil } == true } + + public init(scope: @escaping () -> Scope?, call: @escaping Call, open: @escaping (URL) -> Void, now: @escaping () -> Date = Date.init) { + self.scope = scope + self.call = call + self.open = open + self.now = now + } + + public var scopeIsSignedOut: Bool { scope() == nil } + public var busy: Bool { workingScope != nil && workingScope == scope() } + public var currentConnections: [JSONValue] { loadedScope == scope() ? connections : [] } + public var connected: Bool { currentConnections.contains { $0["status"]?.stringValue == "active" && (canDisconnect($0) || $0["sharing"]?.stringValue == "team") } } + public var ready: Bool { scope() != nil && loadedScope == scope() && configured } + public var canConnect: Bool { ready && !busy && !connected && (!pending || scope().map(resumable) == true) } + + /// A pending flow this launch can resume: its cached browser URL, or the idempotency key of a + /// connect whose response was lost (replaying it returns the same pending connection's URL). + private func resumable(_ bound: Scope) -> Bool { authorizations[bound] != nil || connectKeys[bound] != nil } + + public func canDisconnect(_ row: JSONValue) -> Bool { + scope() != nil && row["created_by"]?.stringValue == scope()?.user + } + + /// One shared entry point for Settings, the Setup Guide, the palette and CLI. + public func connect() async { + let bound = scope() + await perform("githubRefresh") + guard scope() == bound, !error else { return } + if connected { return } + await perform("githubConnect") + } + + public func perform(_ action: String, connection: String? = nil) async { + guard let bound = scope(), workingScope != bound else { return } + generation += 1 + let ticket = generation + workingScope = bound + errorScope = nil + defer { if generation == ticket { workingScope = nil } } + do { + switch action { + case "githubConnect": + // A pending flow from another launch has no resumable URL or key here. + // Keep it pending until refresh or disconnect resolves that flow. + guard ready, !connected, !pending || resumable(bound) else { return } + if let authorization = authorizations[bound], authorization.expires <= now() { + authorizations[bound] = nil + connectKeys[bound] = nil + } + if let authorization = authorizations[bound] { + open(authorization.url) + } else { + if connectKeys[bound] == nil { connectKeys[bound] = UUID().uuidString } + let value = try await call(bound, "integration.connect", ["provider": "github"], connectKeys[bound]) + guard scope() == bound, generation == ticket else { return } + guard let raw = value["authorize_url"]?.stringValue, let url = Self.installationURL(raw), + let id = value["connection"]?["id"]?.stringValue, !id.isEmpty else { throw GitHubFailure.invalidReply } + authorizations[bound] = (id, url, Self.authorizationExpiration(url) ?? now()) + open(url) + } + case "githubDisconnect": + guard let connection, currentConnections.contains(where: { $0["id"]?.stringValue == connection && canDisconnect($0) }) else { return } + _ = try await call(bound, "integration.revoke", ["connection": .string(connection)], UUID().uuidString) + default: break + } + // The same list after every gesture, and on Settings focus. No polling loop. + let value = try await call(bound, "integration.list", [:], nil) + guard scope() == bound, generation == ticket else { return } + guard let rows = value["connections"]?.arrayValue, let providers = value["providers"]?.arrayValue else { + throw GitHubFailure.invalidReply + } + connections = rows.filter { $0["provider"]?.stringValue == "github" && !["revoked", "expired"].contains($0["status"]?.stringValue ?? "") } + configured = providers.contains { $0["provider"]?.stringValue == "github" && $0["configured"]?.boolValue == true } + loadedScope = bound + if let authorization = authorizations[bound], + !connections.contains(where: { $0["id"]?.stringValue == authorization.id && $0["status"]?.stringValue == "pending" }) { + authorizations[bound] = nil + connectKeys[bound] = nil + } + } catch { + if action == "githubConnect", generation == ticket, scope() == bound, + let failure = error as? GitHubFailure, case .refused = failure { + // A definitive API refusal is replayed under its mutation key. + // Transport failures keep the key because the mutation may have succeeded. + connectKeys[bound] = nil + } + if scope() == bound, generation == ticket { errorScope = bound } + } + } + + /// Only the App installation page returned by integration.connect may open. + public nonisolated static func installationURL(_ raw: String) -> URL? { + guard let url = URL(string: raw), url.scheme == "https", url.host == "github.com", + url.user == nil, url.password == nil, url.port == nil, + url.path.range(of: #"^/apps/[A-Za-z0-9-]+/installations/new$"#, options: .regularExpression) != nil else { return nil } + return url + } + + /// Expiry is only a cache hint; the API still verifies the signed state on completion. + private nonisolated static func authorizationExpiration(_ url: URL) -> Date? { + guard let token = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "state" })?.value else { return nil } + let parts = token.split(separator: ".") + guard parts.count == 3 else { return nil } + var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + payload += String(repeating: "=", count: (4 - payload.count % 4) % 4) + guard let data = Data(base64Encoded: payload), let value = try? JSONValue.parse(data), let expires = value["exp"]?.doubleValue else { return nil } + return Date(timeIntervalSince1970: expires) + } + + /// The existing API identity contract (`domains/user.ts::stackTeamIdFor`). + public nonisolated static func backendTeam(project: String, stackTeam: String?) -> String? { + guard let stackTeam else { return nil } + let team = UUID(uuidString: stackTeam) == nil ? stackTeam : stackTeam.lowercased() + let digest = SHA256.hash(data: Data("stack-team:\(project):\(team)".utf8)) + return "team_" + digest.prefix(10).map { String(format: "%02x", $0) }.joined() + } + + public nonisolated static func backendUser(project: String, stackUser: String) -> String { + let digest = SHA256.hash(data: Data("stack:\(project):\(stackUser)".utf8)) + return "user_" + digest.prefix(10).map { String(format: "%02x", $0) }.joined() + } + + public enum GitHubFailure: Error { case changedAccount, invalidReply, refused } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAccounts/Resources/GitHubConnection.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextAccounts/Resources/GitHubConnection.xcstrings new file mode 100644 index 000000000000..991a2fbe92e6 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAccounts/Resources/GitHubConnection.xcstrings @@ -0,0 +1,18 @@ +{ + "sourceLanguage": "en", + "strings": { + "github.connect": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Connect GitHub"}}, "de": {"stringUnit": {"state": "translated", "value": "GitHub verbinden"}}, "fr": {"stringUnit": {"state": "translated", "value": "Connecter GitHub"}}, "ar": {"stringUnit": {"state": "translated", "value": "ربط GitHub"}}, "es": {"stringUnit": {"state": "translated", "value": "Conectar GitHub"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "連接 GitHub"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "连接 GitHub"}}, "ko": {"stringUnit": {"state": "translated", "value": "GitHub 연결"}}, "ja": {"stringUnit": {"state": "translated", "value": "GitHub に接続"}}, "bs": {"stringUnit": {"state": "translated", "value": "Poveži GitHub"}}, "da": {"stringUnit": {"state": "translated", "value": "Forbind GitHub"}}, "it": {"stringUnit": {"state": "translated", "value": "Connetti GitHub"}}, "km": {"stringUnit": {"state": "translated", "value": "ភ្ជាប់ GitHub"}}, "nb": {"stringUnit": {"state": "translated", "value": "Koble til GitHub"}}, "pl": {"stringUnit": {"state": "translated", "value": "Połącz z GitHub"}}, "pt-BR": {"stringUnit": {"state": "translated", "value": "Conectar ao GitHub"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подключить GitHub"}}, "th": {"stringUnit": {"state": "translated", "value": "เชื่อมต่อ GitHub"}}, "tr": {"stringUnit": {"state": "translated", "value": "GitHub'a bağlan"}}, "uk": {"stringUnit": {"state": "translated", "value": "Підключити GitHub"}}, "vi": {"stringUnit": {"state": "translated", "value": "Kết nối GitHub"}}}}, + "github.disconnect": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Disconnect"}}, "de": {"stringUnit": {"state": "translated", "value": "Trennen"}}, "fr": {"stringUnit": {"state": "translated", "value": "Déconnecter"}}, "ar": {"stringUnit": {"state": "translated", "value": "قطع الاتصال"}}, "es": {"stringUnit": {"state": "translated", "value": "Desconectar"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "中斷連線"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "断开连接"}}, "ko": {"stringUnit": {"state": "translated", "value": "연결 해제"}}, "ja": {"stringUnit": {"state": "translated", "value": "接続解除"}}, "bs": {"stringUnit": {"state": "translated", "value": "Prekini vezu"}}, "da": {"stringUnit": {"state": "translated", "value": "Afbryd forbindelsen"}}, "it": {"stringUnit": {"state": "translated", "value": "Disconnetti"}}, "km": {"stringUnit": {"state": "translated", "value": "ផ្តាច់ការភ្ជាប់"}}, "nb": {"stringUnit": {"state": "translated", "value": "Koble fra"}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozłącz"}}, "pt-BR": {"stringUnit": {"state": "translated", "value": "Desconectar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отключить"}}, "th": {"stringUnit": {"state": "translated", "value": "ยกเลิกการเชื่อมต่อ"}}, "tr": {"stringUnit": {"state": "translated", "value": "Bağlantıyı kes"}}, "uk": {"stringUnit": {"state": "translated", "value": "Від’єднати"}}, "vi": {"stringUnit": {"state": "translated", "value": "Ngắt kết nối"}}}}, + "github.confirm": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Disconnect GitHub? Agents will lose access through this connection."}}, "de": {"stringUnit": {"state": "translated", "value": "GitHub trennen? Agenten verlieren den Zugriff über diese Verbindung."}}, "fr": {"stringUnit": {"state": "translated", "value": "Déconnecter GitHub ? Les agents perdront leur accès via cette connexion."}}, "ar": {"stringUnit": {"state": "translated", "value": "هل تريد قطع الاتصال بـ GitHub؟ سيفقد الوكلاء الوصول عبر هذا الاتصال."}}, "es": {"stringUnit": {"state": "translated", "value": "¿Desconectar GitHub? Los agentes perderán el acceso a través de esta conexión."}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "要中斷 GitHub 連線嗎?代理程式將失去透過此連線的存取權。"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "要断开 GitHub 连接吗?代理将失去通过此连接的访问权限。"}}, "ko": {"stringUnit": {"state": "translated", "value": "GitHub 연결을 해제할까요? 에이전트가 이 연결을 통한 액세스 권한을 잃습니다."}}, "ja": {"stringUnit": {"state": "translated", "value": "GitHub との接続を解除しますか?エージェントはこの接続を介してアクセスできなくなります。"}}, "bs": {"stringUnit": {"state": "translated", "value": "Prekinuti vezu s GitHubom? Agenti će izgubiti pristup putem ove veze."}}, "da": {"stringUnit": {"state": "translated", "value": "Vil du afbryde forbindelsen til GitHub? Agenter mister adgangen via denne forbindelse."}}, "it": {"stringUnit": {"state": "translated", "value": "Disconnettere GitHub? Gli agenti perderanno l'accesso tramite questa connessione."}}, "km": {"stringUnit": {"state": "translated", "value": "ផ្តាច់ការភ្ជាប់ GitHub? ភ្នាក់ងារនឹងបាត់បង់សិទ្ធិចូលប្រើតាមរយៈការភ្ជាប់នេះ។"}}, "nb": {"stringUnit": {"state": "translated", "value": "Koble fra GitHub? Agenter mister tilgang via denne tilkoblingen."}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozłączyć GitHub? Agenci utracą dostęp przez to połączenie."}}, "pt-BR": {"stringUnit": {"state": "translated", "value": "Desconectar do GitHub? Os agentes perderão o acesso por esta conexão."}}, "ru": {"stringUnit": {"state": "translated", "value": "Отключить GitHub? Агенты потеряют доступ через это подключение."}}, "th": {"stringUnit": {"state": "translated", "value": "ยกเลิกการเชื่อมต่อ GitHub ไหม นักพัฒนาจะเสียสิทธิ์เข้าถึงผ่านการเชื่อมต่อนี้"}}, "tr": {"stringUnit": {"state": "translated", "value": "GitHub bağlantısı kesilsin mi? Aracılar bu bağlantı üzerinden erişimi kaybedecek."}}, "uk": {"stringUnit": {"state": "translated", "value": "Від’єднати GitHub? Агенти втратять доступ через це підключення."}}, "vi": {"stringUnit": {"state": "translated", "value": "Ngắt kết nối GitHub? Các tác nhân sẽ mất quyền truy cập qua kết nối này."}}}}, + "github.cancel": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Cancel"}}, "de": {"stringUnit": {"state": "translated", "value": "Abbrechen"}}, "fr": {"stringUnit": {"state": "translated", "value": "Annuler"}}, "ar": {"stringUnit": {"state": "translated", "value": "إلغاء"}}, "es": {"stringUnit": {"state": "translated", "value": "Cancelar"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "取消"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "取消"}}, "ko": {"stringUnit": {"state": "translated", "value": "취소"}}, "ja": {"stringUnit": {"state": "translated", "value": "キャンセル"}}, "bs": {"stringUnit": {"state": "translated", "value": "Otkaži"}}, "da": {"stringUnit": {"state": "translated", "value": "Annuller"}}, "it": {"stringUnit": {"state": "translated", "value": "Annulla"}}, "km": {"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": "İptal"}}, "uk": {"stringUnit": {"state": "translated", "value": "Скасувати"}}, "vi": {"stringUnit": {"state": "translated", "value": "Hủy"}}}}, + "github.refresh": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Refresh"}}, "de": {"stringUnit": {"state": "translated", "value": "Aktualisieren"}}, "fr": {"stringUnit": {"state": "translated", "value": "Actualiser"}}, "ar": {"stringUnit": {"state": "translated", "value": "تحديث"}}, "es": {"stringUnit": {"state": "translated", "value": "Actualizar"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "重新整理"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "刷新"}}, "ko": {"stringUnit": {"state": "translated", "value": "새로 고침"}}, "ja": {"stringUnit": {"state": "translated", "value": "更新"}}, "bs": {"stringUnit": {"state": "translated", "value": "Osvježi"}}, "da": {"stringUnit": {"state": "translated", "value": "Opdater"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiorna"}}, "km": {"stringUnit": {"state": "translated", "value": "ផ្ទុកឡើងវិញ"}}, "nb": {"stringUnit": {"state": "translated", "value": "Oppdater"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odśwież"}}, "pt-BR": {"stringUnit": {"state": "translated", "value": "Atualizar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Обновить"}}, "th": {"stringUnit": {"state": "translated", "value": "รีเฟรช"}}, "tr": {"stringUnit": {"state": "translated", "value": "Yenile"}}, "uk": {"stringUnit": {"state": "translated", "value": "Оновити"}}, "vi": {"stringUnit": {"state": "translated", "value": "Làm mới"}}}}, + "github.detail": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Connect the cmux GitHub App. Choose repositories and review permissions in GitHub."}}, "de": {"stringUnit": {"state": "translated", "value": "Verbinde die cmux-GitHub-App. Wähle Repositorys und prüfe die Berechtigungen in GitHub."}}, "fr": {"stringUnit": {"state": "translated", "value": "Connectez l’application GitHub cmux. Choisissez les dépôts et vérifiez les autorisations dans GitHub."}}, "ar": {"stringUnit": {"state": "translated", "value": "اربط تطبيق cmux على GitHub. اختر المستودعات وراجع الأذونات في GitHub."}}, "es": {"stringUnit": {"state": "translated", "value": "Conecta la aplicación de GitHub de cmux. Elige los repositorios y revisa los permisos en GitHub."}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "連接 cmux GitHub 應用程式。在 GitHub 中選擇儲存庫並檢查權限。"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "连接 cmux GitHub 应用。在 GitHub 中选择仓库并检查权限。"}}, "ko": {"stringUnit": {"state": "translated", "value": "cmux GitHub 앱을 연결하세요. GitHub에서 저장소를 선택하고 권한을 검토하세요."}}, "ja": {"stringUnit": {"state": "translated", "value": "cmux GitHub App に接続します。GitHub でリポジトリを選び、権限を確認してください。"}}, "bs": {"stringUnit": {"state": "translated", "value": "Poveži cmux GitHub aplikaciju. Odaberi repozitorije i pregledaj dozvole na GitHubu."}}, "da": {"stringUnit": {"state": "translated", "value": "Forbind cmux GitHub-appen. Vælg repositories, og gennemgå tilladelserne på GitHub."}}, "it": {"stringUnit": {"state": "translated", "value": "Connetti l'app GitHub di cmux. Scegli i repository e controlla le autorizzazioni su GitHub."}}, "km": {"stringUnit": {"state": "translated", "value": "ភ្ជាប់កម្មវិធី GitHub របស់ cmux។ ជ្រើសរើសឃ្លាំង និងពិនិត្យសិទ្ធិនៅលើ GitHub។"}}, "nb": {"stringUnit": {"state": "translated", "value": "Koble til cmux GitHub-appen. Velg repositorier og gå gjennom tillatelser på GitHub."}}, "pl": {"stringUnit": {"state": "translated", "value": "Połącz aplikację GitHub cmux. Wybierz repozytoria i sprawdź uprawnienia w GitHub."}}, "pt-BR": {"stringUnit": {"state": "translated", "value": "Conecte o app do GitHub do cmux. Escolha os repositórios e revise as permissões no GitHub."}}, "ru": {"stringUnit": {"state": "translated", "value": "Подключите приложение cmux для GitHub. Выберите репозитории и проверьте разрешения в GitHub."}}, "th": {"stringUnit": {"state": "translated", "value": "เชื่อมต่อแอป GitHub ของ cmux เลือกที่เก็บและตรวจสอบสิทธิ์ใน GitHub"}}, "tr": {"stringUnit": {"state": "translated", "value": "cmux GitHub uygulamasına bağlanın. Depoları seçin ve GitHub'daki izinleri inceleyin."}}, "uk": {"stringUnit": {"state": "translated", "value": "Підключіть застосунок cmux для GitHub. Виберіть репозиторії та перегляньте дозволи на GitHub."}}, "vi": {"stringUnit": {"state": "translated", "value": "Kết nối ứng dụng GitHub của cmux. Chọn kho lưu trữ và xem lại quyền trên GitHub."}}}}, + "github.signIn": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Sign in to cmux to connect GitHub."}}, "de": {"stringUnit": {"state": "translated", "value": "Melde dich bei cmux an, um GitHub zu verbinden."}}, "fr": {"stringUnit": {"state": "translated", "value": "Connectez-vous à cmux pour connecter GitHub."}}, "ar": {"stringUnit": {"state": "translated", "value": "سجّل الدخول إلى cmux لربط GitHub."}}, "es": {"stringUnit": {"state": "translated", "value": "Inicia sesión en cmux para conectar GitHub."}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "登入 cmux 以連接 GitHub。"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "登录 cmux 以连接 GitHub。"}}, "ko": {"stringUnit": {"state": "translated", "value": "GitHub를 연결하려면 cmux에 로그인하세요."}}, "ja": {"stringUnit": {"state": "translated", "value": "GitHub に接続するには cmux にサインインしてください。"}}, "bs": {"stringUnit": {"state": "translated", "value": "Prijavi se u cmux da povežeš GitHub."}}, "da": {"stringUnit": {"state": "translated", "value": "Log ind på cmux for at forbinde GitHub."}}, "it": {"stringUnit": {"state": "translated", "value": "Accedi a cmux per connettere GitHub."}}, "km": {"stringUnit": {"state": "translated", "value": "ចូល cmux ដើម្បីភ្ជាប់ GitHub។"}}, "nb": {"stringUnit": {"state": "translated", "value": "Logg inn på cmux for å koble til GitHub."}}, "pl": {"stringUnit": {"state": "translated", "value": "Zaloguj się do cmux, aby połączyć GitHub."}}, "pt-BR": {"stringUnit": {"state": "translated", "value": "Entre no cmux para conectar ao GitHub."}}, "ru": {"stringUnit": {"state": "translated", "value": "Войдите в cmux, чтобы подключить GitHub."}}, "th": {"stringUnit": {"state": "translated", "value": "ลงชื่อเข้าใช้ cmux เพื่อเชื่อมต่อ GitHub"}}, "tr": {"stringUnit": {"state": "translated", "value": "GitHub'a bağlanmak için cmux'ta oturum açın."}}, "uk": {"stringUnit": {"state": "translated", "value": "Увійдіть у cmux, щоб підключити GitHub."}}, "vi": {"stringUnit": {"state": "translated", "value": "Đăng nhập cmux để kết nối GitHub."}}}}, + "github.unavailable": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "GitHub connections are unavailable on this deployment."}}, "de": {"stringUnit": {"state": "translated", "value": "GitHub-Verbindungen sind in dieser Bereitstellung nicht verfügbar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Les connexions GitHub ne sont pas disponibles sur ce déploiement."}}, "ar": {"stringUnit": {"state": "translated", "value": "اتصالات GitHub غير متاحة في هذا النشر."}}, "es": {"stringUnit": {"state": "translated", "value": "Las conexiones de GitHub no están disponibles en este despliegue."}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "此部署無法使用 GitHub 連線。"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "此部署无法使用 GitHub 连接。"}}, "ko": {"stringUnit": {"state": "translated", "value": "이 배포에서는 GitHub 연결을 사용할 수 없습니다."}}, "ja": {"stringUnit": {"state": "translated", "value": "このデプロイでは GitHub に接続できません。"}}, "bs": {"stringUnit": {"state": "translated", "value": "GitHub veze nisu dostupne u ovoj instalaciji."}}, "da": {"stringUnit": {"state": "translated", "value": "GitHub-forbindelser er ikke tilgængelige i denne installation."}}, "it": {"stringUnit": {"state": "translated", "value": "Le connessioni GitHub non sono disponibili in questa distribuzione."}}, "km": {"stringUnit": {"state": "translated", "value": "ការភ្ជាប់ GitHub មិនអាចប្រើបាននៅលើការដាក់ពង្រាយនេះទេ។"}}, "nb": {"stringUnit": {"state": "translated", "value": "GitHub-tilkoblinger er utilgjengelige i denne distribusjonen."}}, "pl": {"stringUnit": {"state": "translated", "value": "Połączenia z GitHub są niedostępne w tym wdrożeniu."}}, "pt-BR": {"stringUnit": {"state": "translated", "value": "As conexões do GitHub não estão disponíveis nesta implantação."}}, "ru": {"stringUnit": {"state": "translated", "value": "Подключения к GitHub недоступны в этом развёртывании."}}, "th": {"stringUnit": {"state": "translated", "value": "การเชื่อมต่อ GitHub ไม่พร้อมใช้งานในการปรับใช้นี้"}}, "tr": {"stringUnit": {"state": "translated", "value": "GitHub bağlantıları bu dağıtımda kullanılamıyor."}}, "uk": {"stringUnit": {"state": "translated", "value": "Підключення до GitHub недоступні в цьому розгортанні."}}, "vi": {"stringUnit": {"state": "translated", "value": "Các kết nối GitHub không khả dụng trong bản triển khai này."}}}}, + "github.pending": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Finish connecting in your browser, then refresh."}}, "de": {"stringUnit": {"state": "translated", "value": "Schließe die Verbindung im Browser ab und aktualisiere dann."}}, "fr": {"stringUnit": {"state": "translated", "value": "Terminez la connexion dans votre navigateur, puis actualisez."}}, "ar": {"stringUnit": {"state": "translated", "value": "أكمل الربط في المتصفح، ثم حدّث."}}, "es": {"stringUnit": {"state": "translated", "value": "Completa la conexión en tu navegador y luego actualiza."}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "在瀏覽器中完成連線,然後重新整理。"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "在浏览器中完成连接,然后刷新。"}}, "ko": {"stringUnit": {"state": "translated", "value": "브라우저에서 연결을 완료한 다음 새로 고침하세요."}}, "ja": {"stringUnit": {"state": "translated", "value": "ブラウザで接続を完了してから更新してください。"}}, "bs": {"stringUnit": {"state": "translated", "value": "Dovrši povezivanje u pregledniku, a zatim osvježi."}}, "da": {"stringUnit": {"state": "translated", "value": "Færdiggør forbindelsen i din browser, og opdater derefter."}}, "it": {"stringUnit": {"state": "translated", "value": "Completa la connessione nel browser, poi aggiorna."}}, "km": {"stringUnit": {"state": "translated", "value": "បញ្ចប់ការភ្ជាប់នៅក្នុងកម្មវិធីរុករក រួចផ្ទុកឡើងវិញ។"}}, "nb": {"stringUnit": {"state": "translated", "value": "Fullfør tilkoblingen i nettleseren, og oppdater deretter."}}, "pl": {"stringUnit": {"state": "translated", "value": "Dokończ łączenie w przeglądarce, a następnie odśwież."}}, "pt-BR": {"stringUnit": {"state": "translated", "value": "Conclua a conexão no navegador e depois atualize."}}, "ru": {"stringUnit": {"state": "translated", "value": "Завершите подключение в браузере, затем обновите."}}, "th": {"stringUnit": {"state": "translated", "value": "เชื่อมต่อให้เสร็จในเบราว์เซอร์ แล้วรีเฟรช"}}, "tr": {"stringUnit": {"state": "translated", "value": "Tarayıcınızda bağlantıyı tamamlayın, ardından yenileyin."}}, "uk": {"stringUnit": {"state": "translated", "value": "Завершіть підключення у браузері, потім оновіть."}}, "vi": {"stringUnit": {"state": "translated", "value": "Hoàn tất kết nối trong trình duyệt, sau đó làm mới."}}}}, + "github.failed": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "GitHub could not be updated. Refresh to try again."}}, "de": {"stringUnit": {"state": "translated", "value": "GitHub konnte nicht aktualisiert werden. Aktualisiere, um es erneut zu versuchen."}}, "fr": {"stringUnit": {"state": "translated", "value": "Impossible d’actualiser GitHub. Actualisez pour réessayer."}}, "ar": {"stringUnit": {"state": "translated", "value": "تعذّر تحديث GitHub. حدّث للمحاولة مجددًا."}}, "es": {"stringUnit": {"state": "translated", "value": "No se pudo actualizar GitHub. Actualiza para volver a intentarlo."}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "無法更新 GitHub。請重新整理以重試。"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "无法更新 GitHub。请刷新以重试。"}}, "ko": {"stringUnit": {"state": "translated", "value": "GitHub를 업데이트하지 못했습니다. 새로 고침하여 다시 시도하세요."}}, "ja": {"stringUnit": {"state": "translated", "value": "GitHub を更新できませんでした。更新してもう一度お試しください。"}}, "bs": {"stringUnit": {"state": "translated", "value": "GitHub nije moguće ažurirati. Osvježi i pokušaj ponovo."}}, "da": {"stringUnit": {"state": "translated", "value": "GitHub kunne ikke opdateres. Opdater for at prøve igen."}}, "it": {"stringUnit": {"state": "translated", "value": "Impossibile aggiornare GitHub. Aggiorna per riprovare."}}, "km": {"stringUnit": {"state": "translated", "value": "មិនអាចធ្វើបច្ចុប្បន្នភាព GitHub បានទេ។ ផ្ទុកឡើងវិញដើម្បីព្យាយាមម្ដងទៀត។"}}, "nb": {"stringUnit": {"state": "translated", "value": "GitHub kunne ikke oppdateres. Oppdater for å prøve på nytt."}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie udało się zaktualizować GitHub. Odśwież, aby spróbować ponownie."}}, "pt-BR": {"stringUnit": {"state": "translated", "value": "Não foi possível atualizar o GitHub. Atualize para tentar novamente."}}, "ru": {"stringUnit": {"state": "translated", "value": "Не удалось обновить GitHub. Обновите и попробуйте снова."}}, "th": {"stringUnit": {"state": "translated", "value": "อัปเดต GitHub ไม่ได้ รีเฟรชเพื่อลองอีกครั้ง"}}, "tr": {"stringUnit": {"state": "translated", "value": "GitHub güncellenemedi. Yenileyerek tekrar deneyin."}}, "uk": {"stringUnit": {"state": "translated", "value": "Не вдалося оновити GitHub. Оновіть і спробуйте ще раз."}}, "vi": {"stringUnit": {"state": "translated", "value": "Không thể cập nhật GitHub. Hãy làm mới để thử lại."}}}}, + "github.connected": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Connected"}}, "de": {"stringUnit": {"state": "translated", "value": "Verbunden"}}, "fr": {"stringUnit": {"state": "translated", "value": "Connecté"}}, "ar": {"stringUnit": {"state": "translated", "value": "متصل"}}, "es": {"stringUnit": {"state": "translated", "value": "Conectado"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已連線"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已连接"}}, "ko": {"stringUnit": {"state": "translated", "value": "연결됨"}}, "ja": {"stringUnit": {"state": "translated", "value": "接続済み"}}, "bs": {"stringUnit": {"state": "translated", "value": "Povezano"}}, "da": {"stringUnit": {"state": "translated", "value": "Forbundet"}}, "it": {"stringUnit": {"state": "translated", "value": "Connesso"}}, "km": {"stringUnit": {"state": "translated", "value": "បានភ្ជាប់"}}, "nb": {"stringUnit": {"state": "translated", "value": "Tilkoblet"}}, "pl": {"stringUnit": {"state": "translated", "value": "Połączono"}}, "pt-BR": {"stringUnit": {"state": "translated", "value": "Conectado"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подключено"}}, "th": {"stringUnit": {"state": "translated", "value": "เชื่อมต่อแล้ว"}}, "tr": {"stringUnit": {"state": "translated", "value": "Bağlandı"}}, "uk": {"stringUnit": {"state": "translated", "value": "Підключено"}}, "vi": {"stringUnit": {"state": "translated", "value": "Đã kết nối"}}}}, + "github.reconnect": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Connect GitHub again to restore access."}}, "de": {"stringUnit": {"state": "translated", "value": "Verbinde GitHub erneut, um den Zugriff wiederherzustellen."}}, "fr": {"stringUnit": {"state": "translated", "value": "Reconnectez GitHub pour rétablir l’accès."}}, "ar": {"stringUnit": {"state": "translated", "value": "اربط GitHub مجددًا لاستعادة الوصول."}}, "es": {"stringUnit": {"state": "translated", "value": "Vuelve a conectar GitHub para restaurar el acceso."}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "重新連接 GitHub 以恢復存取權。"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "重新连接 GitHub 以恢复访问权限。"}}, "ko": {"stringUnit": {"state": "translated", "value": "액세스를 복원하려면 GitHub를 다시 연결하세요."}}, "ja": {"stringUnit": {"state": "translated", "value": "アクセスを復元するには GitHub に再接続してください。"}}, "bs": {"stringUnit": {"state": "translated", "value": "Ponovo poveži GitHub da vratiš pristup."}}, "da": {"stringUnit": {"state": "translated", "value": "Forbind GitHub igen for at gendanne adgangen."}}, "it": {"stringUnit": {"state": "translated", "value": "Connetti nuovamente GitHub per ripristinare l'accesso."}}, "km": {"stringUnit": {"state": "translated", "value": "ភ្ជាប់ GitHub ម្តងទៀត ដើម្បីស្ដារសិទ្ធិចូលប្រើ។"}}, "nb": {"stringUnit": {"state": "translated", "value": "Koble til GitHub på nytt for å gjenopprette tilgang."}}, "pl": {"stringUnit": {"state": "translated", "value": "Połącz GitHub ponownie, aby przywrócić dostęp."}}, "pt-BR": {"stringUnit": {"state": "translated", "value": "Conecte o GitHub novamente para restaurar o acesso."}}, "ru": {"stringUnit": {"state": "translated", "value": "Подключите GitHub снова, чтобы восстановить доступ."}}, "th": {"stringUnit": {"state": "translated", "value": "เชื่อมต่อ GitHub อีกครั้งเพื่อกู้คืนสิทธิ์เข้าถึง"}}, "tr": {"stringUnit": {"state": "translated", "value": "Erişimi geri yüklemek için GitHub'a yeniden bağlanın."}}, "uk": {"stringUnit": {"state": "translated", "value": "Підключіть GitHub знову, щоб відновити доступ."}}, "vi": {"stringUnit": {"state": "translated", "value": "Kết nối lại GitHub để khôi phục quyền truy cập."}}}} + }, + "version": "1.0" +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/AccountsActions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/AccountsActions.xcstrings index 01f902d0ce18..d69708314e64 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/AccountsActions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/AccountsActions.xcstrings @@ -917,7 +917,137 @@ } } } + }, + "action.accounts.githubConnect": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect GitHub" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "GitHub verbinden" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connecter GitHub" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ربط GitHub" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectar GitHub" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連接 GitHub" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连接 GitHub" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "GitHub 연결" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "GitHub に接続" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Poveži GitHub" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forbind GitHub" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Connetti GitHub" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ភ្ជាប់ GitHub" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Koble til GitHub" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Połącz z GitHub" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Conectar ao GitHub" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключить GitHub" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เชื่อมต่อ GitHub" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "GitHub'a bağlan" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Підключити GitHub" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Kết nối GitHub" + } + } + } } }, "version": "1.0" -} \ No newline at end of file +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift index 437d6b93db4a..6fd3413a2a92 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift @@ -116,7 +116,7 @@ nonisolated extension ActionSurfaceCatalog { "cloudTunnelAttach", "cloudTunnelDetach", "cloudTunnelRotateKey", "cloudNetworkList", "cloudFirewallList", "cloudFirewallGet", "cloudFirewallCreate", "cloudFirewallDelete", "cloudNewTerminal", "cloudRenameMachine", "cloudShareMachine", "cloudKillMachine", "cloudResizeMachine", "cloudDiagnostics", "cloudPauseMachine", "cloudResumeMachine", "cloudSetIdlePause", "cloudSetNetworkAccess", "cloudOpenDesktop", "cloudDomainList", "cloudPublicationList", "cloudProvisionAPI", "cloudAPIKeyList", "cloudAPIKeyRevoke", "cloudListMachines", "palette.cloud.deleteSnapshot", "palette.auth.signIn", "palette.auth.signOut", "accounts.show", "palette.mobileConnect", "accounts.refresh", - "accounts.reauthenticate", "accounts.connect", "accounts.remove", "remote.connect", "remote.newGroup", "remote.removeGroup", "machine.reconnect", "remote.newWorkspace", + "accounts.reauthenticate", "accounts.connect", "accounts.remove", "accounts.githubConnect", "remote.connect", "remote.newGroup", "remote.removeGroup", "machine.reconnect", "remote.newWorkspace", "remote.openTerminalHere", "remote.openRemote", "remote.reconnect", "remote.disconnect", "remote.install", "remote.forget", "device.add", "machine.add", "machine.doctor", "reloadConfiguration", "openSettings", "palette.makeDefaultBrowser", "palette.makeDefaultTerminal", "palette.toggleSetting", @@ -340,7 +340,7 @@ nonisolated extension ActionSurfaceCatalog { ], .credentials: [ "palette.auth.signIn", "palette.auth.signOut", "accounts.reauthenticate", "accounts.connect", - "accounts.remove", + "accounts.remove", "accounts.githubConnect", ], .endsApp: [ "quit", "quitKeepSessions", "quitEndSessions", "quitEndEverything", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift index ad40c368b91e..c8ccf954ae97 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift @@ -44,7 +44,7 @@ nonisolated extension ActionSurfaceCatalog { "palette.computerUse.accessibility", "palette.computerUse.screenRecording", "palette.computerUse.restart", "computerUseStop", "newCloudMachine", "cloudDiagnostics", "openTeamPicker", "palette.auth.signIn", "palette.auth.signOut", "palette.mobileConnect", "accounts.show", "accounts.refresh", "accounts.reauthenticate", - "accounts.connect", "accounts.remove", "reloadConfiguration", "palette.openCmuxSettingsFile", + "accounts.connect", "accounts.remove", "accounts.githubConnect", "reloadConfiguration", "palette.openCmuxSettingsFile", "palette.openGhosttySettings", "settings.customizeMenus", "ghostty.showDiagnostics", "palette.makeDefaultBrowser", "palette.makeDefaultTerminal", "palette.toggleSetting", "palette.shortcutKeymap", "palette.searchShortcuts", "palette.installCLI", "palette.uninstallCLI", "palette.restartSocketListener", "palette.restartServer", "palette.checkForUpdates", "updates.whatsNew", "announcements.show", "announcements.hide", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AccountActionCatalog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AccountActionCatalog.swift index f2d0ba31d341..e5051773b5f0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AccountActionCatalog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AccountActionCatalog.swift @@ -6,6 +6,11 @@ nonisolated enum AccountActionCatalog: ActionCatalogGroup { static func descriptors() -> [ActionDescriptor] { var actions = [ + ActionDescriptor( + id: "accounts.githubConnect", title: text("action.accounts.githubConnect", "Connect GitHub"), + keywords: ["github", "repositories", "integration"], category: .cloud, symbol: "link", + surfaces: [.palette], cliName: "accounts github-connect" + ), ActionDescriptor( id: "accounts.show", title: text("action.accounts.show", "Accounts…"), @@ -43,7 +48,7 @@ nonisolated enum AccountActionCatalog: ActionCatalogGroup { ] // Connect and remove are CodeRouter round trips: the CLI waits for // the outcome and exits non-zero when it failed. - for index in actions.indices where ["accounts.connect", "accounts.remove"].contains(actions[index].id.rawValue) { + for index in actions.indices where ["accounts.connect", "accounts.remove", "accounts.githubConnect"].contains(actions[index].id.rawValue) { actions[index].waitsForResult = true } return actions diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Accounts/AccountsService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Accounts/AccountsService.swift index ee07a823fb70..40cb7203aac7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Accounts/AccountsService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Accounts/AccountsService.swift @@ -1,8 +1,10 @@ +import CmuxNextPages import AppKit public import CmuxNextAccounts import CmuxNextCloud import CmuxNextCodeRouter -import CmuxNextDaemon +import CmuxNextSettings +import struct CmuxNextDaemon.LoginEnvironment import Foundation import os import CmuxNextCompat @@ -16,6 +18,7 @@ import CmuxNextCompat final class AccountsService: AccountsServices { unowned let services: AppServices private(set) lazy var model = AccountsModel(services: self) + private(set) lazy var github = GitHubConnections.live(cloud: services.cloud, preview: services.environment.showcase || fixtureHome != nil) let keys: any ProviderKeyStoring /// The per-user salt behind every `acct_…` handle (Keychain, read once off the main actor). let labels: AccountLabelerStore @@ -187,3 +190,39 @@ extension CloudAuth: CmuxAccountSource {} private struct NoKeychain: KeychainProbing { func hasGenericPassword(service: String) -> Bool { false } } + +extension GitHubConnections { + static func signedInScope(project: String, user: String, team: String?, managedTeam: String?) -> Scope? { + guard managedTeam == nil || team == managedTeam else { return nil } + return Scope(user: backendUser(project: project, stackUser: user), team: team) + } + + static func live(cloud: CloudService, preview: Bool) -> GitHubConnections { + let auth = cloud.auth + let transport = InstallHTTPTransport(baseURL: cloud.configuration.ownerAPIBaseURL()) + let current: @MainActor () -> Scope? = { + guard !preview, auth.isSignedIn, let user = auth.user?.id else { return nil } + return signedInScope(project: auth.configuration.stackProjectID, user: user, team: auth.teamID, managedTeam: auth.managedTeamID) + } + return GitHubConnections(scope: current, call: { bound, op, params, mutationKey in + let access = try await auth.tokens().access + guard current() == bound else { throw GitHubFailure.changedAccount } + var body: [String: JSONValue] = ["op": .string(op), "params": params] + if let mutationKey { + body["origin"] = "user" + body["idempotency_key"] = .string(mutationKey) + } + var headers: [String: String] = [:] + if let team = backendTeam(project: auth.configuration.stackProjectID, stackTeam: bound.team) { headers["x-cmux-team"] = team } + let response = try await transport.post(mutationKey != nil ? "v1/ops" : "v1/read", json: Data(JSONValue.object(body).compactText.utf8), bearer: access, headers: headers) + guard current() == bound else { throw GitHubFailure.changedAccount } + // A timeout, throttling response or server failure does not prove that + // the mutation was refused. Keep its key for a safe retry. + guard response.status < 500, response.status != 408, response.status != 429 else { throw URLError(.badServerResponse) } + guard (200..<300).contains(response.status) else { throw GitHubFailure.refused } + let reply = try JSONValue.parse(response.body) + guard (reply["error"] == nil || reply["error"] == .null), reply["ok"] != .bool(false), let value = reply["value"] else { throw GitHubFailure.refused } + return value + }, open: { url in LinkRouter.shared.open(url) }) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AccountsHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AccountsHandlers.swift index 1aa85256ed4a..713dd7f606b6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AccountsHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AccountsHandlers.swift @@ -1,4 +1,5 @@ import CmuxNextActions +import CmuxNextAccounts import CmuxNextCodeRouter import CmuxNextSettings import Foundation @@ -12,6 +13,10 @@ enum AccountsHandlers { static func bind(into registry: ActionRegistry, context: AppActionContext) { let services = context.services registry.bind("accounts.show", run: { _ in try services.settingsWindow.show(section: .accounts) }) + registry.bindEffect("accounts.githubConnect", requires: nil, effect: { _ in + try services.settingsWindow.show(section: .accounts) + return .applied(result: { await githubConnectResult(services.accounts.github) }) + }) registry.bind("accounts.refresh", run: { _ in services.accounts.model.refresh() }) registry.bind("accounts.reauthenticate", run: { invocation in let provider = try provider(invocation) @@ -46,6 +51,14 @@ enum AccountsHandlers { }) } + static func githubConnectResult(_ github: GitHubConnections) async -> Result<[String: ActionResultValue], ActionWorkFailure> { + await github.connect() + let state = github.pageState + let detail = state["detail"]?.stringValue ?? "" + if github.error { return .failure(ActionWorkFailure(detail)) } + return .success(["status": .string(state["outcome"]?.stringValue ?? "needs-action"), "detail": .string(detail)]) + } + private static func provider(_ invocation: ActionInvocation) throws -> AIProvider { let raw = invocation["provider"]?.stringValue ?? "" guard let provider = AIProvider(rawValue: raw.lowercased()) else { throw ActionFailure(message: AccountsAppStrings.unknownProvider(raw)) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift index b11d56c7dea7..976d7564afa7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift @@ -155,12 +155,25 @@ struct PageFactory { return await services.cloudVmHost.set(id, on: on, reachable: services.settingsWindow.vmHostReach(id) == true) } let accounts = services.accounts.model - provider.accountsState = { (try? JSONValue.parse(JSONEncoder().encode(accounts.pageState))) ?? .null } + let github = services.accounts.github + provider.accountsState = { + guard let encoded = try? JSONValue.parse(JSONEncoder().encode(accounts.pageState)), case .object(var state) = encoded else { return .null } + state["github"] = github.pageState + return .object(state) + } provider.accountsRun = { params in + let gesture = params["action"]?.stringValue ?? "" + if gesture.hasPrefix("github") { + guard ["githubConnect", "githubDisconnect", "githubRefresh"].contains(gesture) else { throw PageError.invalidParams("unknown GitHub action") } + if gesture == "githubConnect" { await github.connect() } + else { await github.perform(gesture, connection: params["account"]?.stringValue) } + return .object([:]) + } guard let action = AccountsPageAction(field: { params[$0]?.stringValue }) else { throw PageError.invalidParams("unknown accounts action") } if let error = await accounts.perform(action) { return ["error": .string(error)] } + if ["refresh", "focus", "selectTeam"].contains(gesture) { await github.perform("githubRefresh") } return .object([:]) } provider.agents = services.agentHarnesses diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Feed.generated.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Feed.generated.swift index 4cd3c85a2b3d..bab312def6c6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Feed.generated.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Feed.generated.swift @@ -21,8 +21,8 @@ nonisolated enum FeedSettingsSchema { FeedGitHubSettings.enabledPath, section: .notifications, group: group, - title: SettingsText.keyed("settings.feed.github.enabled", "Connect GitHub"), - help: SettingsText.keyed("settings.feed.github.enabled.help", "Uses your gh login to read notifications and review requests on this Mac. Sign in with gh auth login first."), + title: SettingsText.keyed("settings.group.githubInbox", "GitHub Inbox"), + help: SettingsText.keyed("settings.feed.github.enabled.summary", "Inbox shows your GitHub notifications"), summary: SettingsText.keyed("settings.feed.github.enabled.summary", "Inbox shows your GitHub notifications"), kind: .toggle, default: .bool(FeedGitHubSettings.enabledDefault), diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/GitHubConnectionsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/GitHubConnectionsTests.swift new file mode 100644 index 000000000000..570fda62bfc8 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/GitHubConnectionsTests.swift @@ -0,0 +1,318 @@ +import CmuxNextActions +import CmuxNextAccounts +import CmuxNextSettings +import Foundation +import Testing +@testable import CmuxNextApp + +@MainActor +@Suite struct GitHubConnectionsTests { + private let scope = GitHubConnections.Scope(user: "user", team: nil) + private var listing: JSONValue { + ["connections": .array([]), "providers": [["provider": "github", "configured": true]]] + } + + @Test func signedOutNeverCallsTheAPI() async { + var calls = 0 + let model = GitHubConnections(scope: { nil }, call: { _, _, _, _ in calls += 1; return self.listing }, open: { _ in }) + await model.perform("githubConnect") + await model.perform("githubRefresh") + await model.connect() + #expect(model.pageState["outcome"] == "needs-action") + #expect(calls == 0) + #expect(!model.ready) + } + + @Test func connectUsesTheAppInstallPageAndDoesNotPretendItIsConnected() async { + var operations: [String] = [] + var opened: [URL] = [] + var connected = false + let model = GitHubConnections(scope: { self.scope }, call: { _, op, params, mutation in + operations.append(op) + if op == "integration.connect" { + #expect(params == ["provider": "github"]) + #expect(mutation != nil) + connected = true + return ["connection": ["id": "sample"], "authorize_url": "https://github.com/apps/cmux/installations/new?state=a.eyJleHAiOjQxMDI0NDQ4MDB9.b"] + } + if connected { return ["connections": [["id": "sample", "provider": "github", "status": "pending"]], "providers": [["provider": "github", "configured": true]]] } + return self.listing + }, open: { opened.append($0) }) + await model.perform("githubRefresh") + #expect(model.ready) + await model.perform("githubConnect") + #expect(operations == ["integration.list", "integration.connect", "integration.list"]) + #expect(opened.count == 1) + #expect(model.pending) + #expect(model.currentConnections.first?["status"] == "pending") + await model.perform("githubConnect") + #expect(operations.filter { $0 == "integration.connect" }.count == 1) + #expect(opened.count == 2) + } + + @Test func aResponseForTheOldAccountCannotPopulateTheNewAccount() async { + var current: GitHubConnections.Scope? = self.scope + let model = GitHubConnections(scope: { current }, call: { _, _, _, _ in + current = .init(user: "another-user", team: nil) + return ["connections": [["id": "old", "provider": "github", "status": "active"]], "providers": [["provider": "github", "configured": true]]] + }, open: { _ in }) + await model.perform("githubRefresh") + #expect(model.currentConnections.isEmpty) + #expect(!model.ready) + #expect(!model.busy) + } + + @Test func unknownConnectionCannotBeRevoked() async { + var calls = 0 + let model = GitHubConnections(scope: { self.scope }, call: { _, _, _, _ in calls += 1; return self.listing }, open: { _ in }) + await model.perform("githubDisconnect", connection: "not-captured") + #expect(calls == 0) + } + + @Test func disconnectOnlyChangesAnOwnedConnectionAndRefreshesItsState() async { + var operations: [String] = [] + var revoked = false + let model = GitHubConnections(scope: { self.scope }, call: { _, op, params, mutation in + operations.append(op) + if op == "integration.revoke" { + #expect(params == ["connection": "mine"]) + #expect(mutation != nil) + revoked = true + return [:] + } + return ["connections": [ + ["id": "mine", "provider": "github", "created_by": "user", "status": revoked ? "revoked" : "active"], + ["id": "shared", "provider": "github", "created_by": "another-user", "status": "active"], + ], "providers": [["provider": "github", "configured": true]]] + }, open: { _ in }) + await model.perform("githubRefresh") + await model.perform("githubDisconnect", connection: "shared") + #expect(operations == ["integration.list"]) + await model.perform("githubDisconnect", connection: "mine") + #expect(operations == ["integration.list", "integration.revoke", "integration.list"]) + #expect(model.currentConnections.count == 1) + #expect(model.currentConnections.first?["id"] == "shared") + } + + @Test func failedRefreshHasRecoveryAndDoesNotEraseKnownConnections() async { + var fail = false + var current = self.scope + let model = GitHubConnections(scope: { current }, call: { _, _, _, _ in + if fail { throw GitHubConnections.GitHubFailure.refused } + return self.listing + }, open: { _ in }) + await model.perform("githubRefresh") + fail = true + await model.perform("githubRefresh") + #expect(model.error) + #expect(model.pageState["refresh"]?.stringValue?.isEmpty == false) + current = .init(user: "another-user", team: nil) + #expect(!model.error) + #expect(!model.ready) + } + + @Test func lostConnectResponseReusesItsIdempotencyKey() async { + var keys: [String?] = [] + let model = GitHubConnections(scope: { self.scope }, call: { _, op, _, key in + if op == "integration.connect" { + keys.append(key) + if keys.count == 1 { throw URLError(.networkConnectionLost) } + return ["connection": ["id": "sample"], "authorize_url": "https://github.com/apps/cmux/installations/new?state=a.eyJleHAiOjQxMDI0NDQ4MDB9.b"] + } + return self.listing + }, open: { _ in }) + await model.connect() + #expect(model.error) + await model.connect() + #expect(keys.count == 2) + #expect(keys.first! != nil) + #expect(keys[0] == keys[1]) + } + + @Test func aLostConnectResponseWithAServerPendingRowRetriesUnderItsKey() async { + var keys: [String?] = [] + var opened: [URL] = [] + var created = false + let model = GitHubConnections(scope: { self.scope }, call: { _, op, _, key in + if op == "integration.connect" { + keys.append(key) + created = true + if keys.count == 1 { throw URLError(.networkConnectionLost) } + return ["connection": ["id": "sample"], "authorize_url": "https://github.com/apps/cmux/installations/new?state=a.eyJleHAiOjQxMDI0NDQ4MDB9.b"] + } + // The lost connect did create the server's pending row. + let rows: [JSONValue] = created ? [["id": "sample", "provider": "github", "created_by": "user", "status": "pending"]] : [] + return ["connections": .array(rows), "providers": [["provider": "github", "configured": true]]] + }, open: { opened.append($0) }) + await model.connect() + #expect(model.error) + #expect(model.canConnect, "the held key makes the pending flow resumable") + await model.connect() + #expect(keys.count == 2) + #expect(keys[0] != nil && keys[0] == keys[1]) + #expect(opened.count == 1) + } + + @Test func policyRejectionDoesNotReplayAfterPolicyChanges() async throws { + var keys: [String] = [] + let model = GitHubConnections(scope: { self.scope }, call: { _, op, _, key in + if op == "integration.connect" { + keys.append(try #require(key)) + if keys.count == 1 { throw GitHubConnections.GitHubFailure.refused } + return ["connection": ["id": "sample"], "authorize_url": "https://github.com/apps/cmux/installations/new?state=a.eyJleHAiOjQxMDI0NDQ4MDB9.b"] + } + return self.listing + }, open: { _ in }) + await model.connect() + #expect(model.error) + await model.connect() + #expect(!model.error) + try #require(keys.count == 2) + #expect(keys[0] != keys[1]) + } + + @Test func aRefusalFromAnOlderGenerationCannotClearANewKey() async throws { + var current: GitHubConnections.Scope? = self.scope + let requests = AsyncStream>.makeStream() + var events = requests.stream.makeAsyncIterator() + var keys: [String] = [] + let model = GitHubConnections(scope: { current }, call: { _, op, _, key in + guard op == "integration.connect" else { return self.listing } + keys.append(try #require(key)) + if keys.count == 1 || keys.count == 3 { + return try await withCheckedThrowingContinuation { requests.continuation.yield($0) } + } + throw GitHubConnections.GitHubFailure.refused + }, open: { _ in }) + let first = Task { + await model.connect() + requests.continuation.finish() + } + let oldest = try #require(await events.next()) + current = .init(user: "other", team: nil) + await model.perform("githubRefresh") + current = self.scope + await model.connect() // Current refusal of K1 permits a new key. + let second = Task { + await model.connect() + requests.continuation.finish() + } + let latest = try #require(await events.next()) + oldest.resume(throwing: GitHubConnections.GitHubFailure.refused) + await first.value + latest.resume(throwing: URLError(.networkConnectionLost)) + await second.value + await model.connect() + try #require(keys.count == 4) + #expect(keys[0] == keys[1]) + #expect(keys[1] != keys[2]) + #expect(keys[2] == keys[3]) // Obsolete K1 failure must not discard K2. + } + + @Test func managedTeamCannotFallBackToPersonalIntegration() async { + var team: String? + var calls = 0 + let model = GitHubConnections(scope: { + GitHubConnections.signedInScope(project: "project", user: "user", team: team, managedTeam: "managed") + }, call: { bound, _, _, _ in + calls += 1 + #expect(bound.team == "managed") + return self.listing + }, open: { _ in }) + await model.connect() + #expect(calls == 0) + team = "another-team" + await model.connect() + #expect(calls == 0) + team = "managed" + await model.perform("githubRefresh") + #expect(calls == 1) + #expect(model.ready) + #expect(GitHubConnections.signedInScope(project: "project", user: "user", team: nil, managedTeam: nil)?.team == nil) + #expect(GitHubConnections.signedInScope(project: "project", user: "user", team: nil, managedTeam: nil) != nil) + } + + @Test func restoredPendingConnectionDoesNotCreateADuplicate() async { + var connects = 0 + var opened = 0 + let model = GitHubConnections(scope: { self.scope }, call: { _, op, _, _ in + if op == "integration.connect" { connects += 1 } + return ["connections": [["id": "pending", "provider": "github", "created_by": "user", "status": "pending"]], "providers": [["provider": "github", "configured": true]]] + }, open: { _ in opened += 1 }) + await model.connect() + #expect(model.pageState["canConnect"] == false) + #expect(model.pageState["outcome"] == "pending") + await model.perform("githubConnect") + #expect(connects == 0) + #expect(opened == 0) + } + + @Test func sharedConnectDoesNotCreateAnotherActiveConnection() async { + var connects = 0 + let model = GitHubConnections(scope: { self.scope }, call: { _, op, _, _ in + if op == "integration.connect" { connects += 1 } + return ["connections": [["id": "mine", "provider": "github", "created_by": "user", "status": "active"]], "providers": [["provider": "github", "configured": true]]] + }, open: { _ in }) + await model.connect() + await model.connect() + #expect(connects == 0) + #expect(model.pageState["outcome"] == "connected") + } + + @Test func anActiveSharedConnectionIsConnectedButCannotBeRevoked() async { + var mutations = 0 + let model = GitHubConnections(scope: { self.scope }, call: { _, op, _, _ in + if op != "integration.list" { mutations += 1 } + return ["connections": [["id": "shared", "provider": "github", "created_by": "another-user", "sharing": "team", "status": "active"]], "providers": [["provider": "github", "configured": true]]] + }, open: { _ in }) + await model.connect() + await model.perform("githubDisconnect", connection: "shared") + #expect(model.connected) + #expect(model.pageState["outcome"] == "connected") + #expect(model.pageState["canConnect"] == false) + #expect(model.pageState["connections"]?.arrayValue?.first?["canDisconnect"] == false) + #expect(mutations == 0) + } + + @Test func anExpiredAuthorizationStartsANewAttempt() async { + var clock = Date(timeIntervalSince1970: 4102444799) + var keys: [String?] = [] + let model = GitHubConnections(scope: { self.scope }, call: { _, op, _, key in + if op == "integration.connect" { + keys.append(key) + return ["connection": ["id": "sample"], "authorize_url": "https://github.com/apps/cmux/installations/new?state=a.eyJleHAiOjQxMDI0NDQ4MDB9.b"] + } + if keys.isEmpty { return self.listing } + return ["connections": [["id": "sample", "provider": "github", "status": "pending"]], "providers": [["provider": "github", "configured": true]]] + }, open: { _ in }, now: { clock }) + await model.connect() + clock = clock.addingTimeInterval(1) + await model.connect() + #expect(keys.count == 2) + #expect(keys[0] != keys[1]) + } + + @Test func theCLIReportsAConnectionFailureAsFailure() async { + let model = GitHubConnections(scope: { self.scope }, call: { _, _, _, _ in throw GitHubConnections.GitHubFailure.refused }, open: { _ in }) + let result = await AccountsHandlers.githubConnectResult(model) + guard case .failure(let error) = result else { Issue.record("the command reported success after a failed request"); return } + #expect(!error.message.isEmpty) + #expect(model.pageState["outcome"] == "failed") + } + + @Test func theGuideAndCLIShareAWaitingConnectAction() throws { + let action = try #require(ActionRegistry.standard().descriptor(for: "accounts.githubConnect")) + #expect(action.cliName == "accounts github-connect") + #expect(action.waitsForResult) + } + + @Test(arguments: ["http://github.com/apps/cmux/installations/new", "https://example.test/apps/cmux/installations/new", "https://github.com@evil.test/apps/cmux/installations/new", "https://github.com/login", "https://github.com:443/apps/cmux/installations/new"]) + func onlyGitHubAppInstallationURLsOpen(_ url: String) { + #expect(GitHubConnections.installationURL(url) == nil) + } + + @Test func backendTeamUsesTheExistingIdentityContract() { + #expect(GitHubConnections.backendTeam(project: "project", stackTeam: "team") == "team_3051e6705a6a35841b74") + #expect(GitHubConnections.backendTeam(project: "project", stackTeam: nil) == nil) + } +} diff --git a/schemas/defaults/cmux-next.json b/schemas/defaults/cmux-next.json index 68f09fadec37..0d04e4cb594d 100644 --- a/schemas/defaults/cmux-next.json +++ b/schemas/defaults/cmux-next.json @@ -2085,7 +2085,8 @@ "tunable-source.CmuxNextApp/Groups/GroupOwnership.swift:pane#": "ea9fb3f1620a3181925ccb65b0db03ee8d1390a944f74b70bbdfe5d3ce1069fa", "tunable-source.CmuxNextApp/Groups/GroupOwnership.swift:screenGroup": "9bf30d8126208410f0cb5d8232a290c632a64da299a8f534a483b5ea789fa253", "tunable-source.CmuxNextApp/Groups/GroupOwnership.swift:workspace": "6ef65d6041005cf1d213febee060323f7431261cd74e70e732b2dcb399ce71ca", - "tunable-source.CmuxNextApp/Handlers/AccountsHandlers.swift:bind": "1d726c4a502f628fa8e9b9eb05f268d98d0a549389b4ffd5d4926b550385a650", + "tunable-source.CmuxNextApp/Handlers/AccountsHandlers.swift:bind": "df9b051651bc930469164629a242646c8e4e2e7c08818b993f65171175983617", + "tunable-source.CmuxNextApp/Handlers/AccountsHandlers.swift:githubConnectResult": "eae0623357f1aeda6d1253abe95ef06f3663671088418ae8138d82cccb54c9d8", "tunable-source.CmuxNextApp/Handlers/AccountsHandlers.swift:provider": "58b2a039bf81bf2ef228f4f1901eb2497aca445c97ed92558bcaa215913f3bfd", "tunable-source.CmuxNextApp/Handlers/AddHarnessHandler.swift:bind": "e5474f6c8c19ddbff57017cfa68cd9032d3e30055ee3f911bec2feb8c19afb9e", "tunable-source.CmuxNextApp/Handlers/AddHarnessHandler.swift:needsFocus": "d15bd721667ea60bd8cfceec2104c0029f85d0a45fa15955e3ba887116ef284b", @@ -6124,7 +6125,7 @@ "tunable-source.CmuxNextSettings/Schema/SettingsSchema+ColorTransition.generated.swift:springResponsePath": "2d5b0fe1cd68ec7d80df9a0b551a73c468a83919e45d45134a5c0448568191ca", "tunable-source.CmuxNextSettings/Schema/SettingsSchema+ColorTransition.generated.swift:springResponseRange": "8f6fd93110cfcfd50fb4c1554c485117d75bbbf15a2985d196fb04bfa3562b07", "tunable-source.CmuxNextSettings/Schema/SettingsSchema+ColumnLayout.swift:descriptors": "f3b34f26347f92c01a27a5d73245ee3c10b44f5dfeb56bb268c74789531bf1c0", - "tunable-source.CmuxNextSettings/Schema/SettingsSchema+Feed.generated.swift:descriptors": "70aaf7541ea894edf03c43e350d86e18c13434cc1922d4cb9256915bb354988f", + "tunable-source.CmuxNextSettings/Schema/SettingsSchema+Feed.generated.swift:descriptors": "c1434b258a2d40e4bcfb95b56872c7bb8573782098103fcf548b8909fab10626", "tunable-source.CmuxNextSettings/Schema/SettingsSchema+Feed.generated.swift:enabledDefault": "24f040d60e699a35706ca65ce11bdd7c3e9bc4699005bfefda39c1a9ec638fea", "tunable-source.CmuxNextSettings/Schema/SettingsSchema+Feed.generated.swift:enabledPath": "67991ca9f4bd4c6f612c89287ef82d567b0a7ab2308ae7058b6039b15e2ced57", "tunable-source.CmuxNextSettings/Schema/SettingsSchema+Feed.generated.swift:pollIntervalDefault": "81e8cec5b81d19bd27b35f433e1bf14e8af20699515a38f8f0694bb2f2b06e7b", diff --git a/schemas/settings/source/feed.json b/schemas/settings/source/feed.json index 17ca844aa536..4d9d7a65b892 100644 --- a/schemas/settings/source/feed.json +++ b/schemas/settings/source/feed.json @@ -14,12 +14,12 @@ "key": "feed.github.enabled", "note": "The opt-in connection: this Mac reads GitHub notifications and review requests with the gh login.", "title": { - "key": "settings.feed.github.enabled", - "text": "Connect GitHub" + "key": "settings.group.githubInbox", + "text": "GitHub Inbox" }, "help": { - "key": "settings.feed.github.enabled.help", - "text": "Uses your gh login to read notifications and review requests on this Mac. Sign in with gh auth login first." + "key": "settings.feed.github.enabled.summary", + "text": "Inbox shows your GitHub notifications" }, "summary": { "key": "settings.feed.github.enabled.summary", diff --git a/webviews/src/pages/settings/components/AccountsSection.tsx b/webviews/src/pages/settings/components/AccountsSection.tsx index f4b8b289abb5..2cec26d3a02a 100644 --- a/webviews/src/pages/settings/components/AccountsSection.tsx +++ b/webviews/src/pages/settings/components/AccountsSection.tsx @@ -9,6 +9,7 @@ import { Select } from "../editors/Select"; import type { AccountsButton, AccountsCmux, AccountsRow, AccountsTeams } from "../ops"; import { t } from "../strings"; import { TeamsGroup } from "./AccountsTeams"; +import { GitHubAccountsCard } from "./GitHubAccountsCard"; export function AccountsSection() { const store = useStore(); @@ -69,6 +70,7 @@ function AccountsBody() { {accounts.teams && } {accounts.teamList && } + {accounts.groups.map((group) => (

{group.title}

diff --git a/webviews/src/pages/settings/components/GitHubAccountsCard.tsx b/webviews/src/pages/settings/components/GitHubAccountsCard.tsx new file mode 100644 index 000000000000..7ff9fd934751 --- /dev/null +++ b/webviews/src/pages/settings/components/GitHubAccountsCard.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { useSettingsState, useStore } from "../context"; + +/** The host owns GitHub App authentication; this card never receives a credential. */ +export function GitHubAccountsCard() { + const github = useSettingsState().accounts?.github; + const store = useStore(); + const [confirming, setConfirming] = useState(null); + if (!github) return null; + return ( +
+

{github.title}

+
+
+
+

{github.detail}

+
+
+
+ + +
+
+
+ {github.connections.map((connection) => ( +
+
+ {connection.account} +

{connection.status}

+

{connection.permissions.join(" · ")}

+ {confirming === connection.id && {github.confirm}} +
+
+
+ {confirming === connection.id ? ( + <> + + + + ) : ( + + )} +
+
+
+ ))} +
+
+ ); +} diff --git a/webviews/src/pages/settings/ops.ts b/webviews/src/pages/settings/ops.ts index af978b3e1d25..b990026e4652 100644 --- a/webviews/src/pages/settings/ops.ts +++ b/webviews/src/pages/settings/ops.ts @@ -494,7 +494,21 @@ export type AccountsTeamList = { }; /** The Accounts part as the host draws it (texts already localized by the app). */ +export type GitHubConnectionState = { + title: string; + detail: string; + busy: boolean; + connect: string; + disconnect: string; + confirm: string; + cancel: string; + refresh: string; + canConnect: boolean; + connections: Array<{ id: string; account: string; status: string; permissions: string[]; canDisconnect: boolean }>; +}; + export type AccountsState = { + github?: GitHubConnectionState; teams: AccountsTeams | null; /** Older hosts omit it. */ teamList?: AccountsTeamList | null; diff --git a/webviews/src/pages/settings/settings.gallery.ts b/webviews/src/pages/settings/settings.gallery.ts index d783c5278402..157a3775bf62 100644 --- a/webviews/src/pages/settings/settings.gallery.ts +++ b/webviews/src/pages/settings/settings.gallery.ts @@ -1351,6 +1351,83 @@ const resetAnchors = Object.values(RESET_SAMPLES).flatMap((key) => [ { selector: `${row(key)} + [data-row-key] .row-title` }, ]); +const githubConnection = { + title: "GitHub", + detail: "Connected", + busy: false, + connect: "Connect GitHub", + disconnect: "Disconnect", + confirm: "Disconnect GitHub? Agents will lose access through this connection.", + cancel: "Cancel", + refresh: "Refresh", + canConnect: false, + connections: [ + { + id: "sample-github", + canDisconnect: true, + account: "sample-team", + status: "Connected", + permissions: ["contents:write", "pull_requests:write"], + }, + ], +}; +for (const treatment of ["balanced", "compact", "inline"]) { + variants[`github-connect-${treatment}`] = variant("accounts", { + accounts: accounts({}, { github: githubConnection }), + note: `GitHub App connection: ${treatment} treatment. Balanced uses the existing Accounts spacing.`, + play: async (ctx) => { + await ctx.waitFor(() => ctx.document.querySelector('[data-card="github-connection"]')); + const card = ctx.document.querySelector('[data-card="github-connection"]')!; + card.scrollIntoView({ block: "center" }); + if (treatment !== "balanced") { + const style = ctx.document.createElement("style"); + style.textContent = + treatment === "compact" + ? '[data-card="github-connection"] .row { padding-block: 4px; } [data-card="github-connection"] .accounts-body { margin-top: 4px; }' + : '[data-card="github-connection"] .row { display: flex; align-items: center; gap: 16px; } [data-card="github-connection"] .accounts-body { margin: 0; padding: 0; }'; + card.append(style); + } + }, + }); +} +variants["github-connect-comparison"] = variant("accounts", { + accounts: accounts({}, { github: githubConnection }), +}); +variants["github-connect-empty"] = variant("accounts", { + accounts: accounts( + {}, + { + github: { + ...githubConnection, + connections: [], + canConnect: true, + detail: "Connect the cmux GitHub App. Choose repositories and review permissions in GitHub.", + }, + }, + ), +}); +variants["github-connect-pending"] = variant("accounts", { + accounts: accounts( + {}, + { + github: { + ...githubConnection, + connections: [], + canConnect: true, + detail: "Finish connecting in your browser, then refresh.", + }, + }, + ), +}); +variants["github-connect-confirm"] = variant("accounts", { + accounts: accounts({}, { github: githubConnection }), + play: async (ctx) => { + await ctx.waitFor(() => ctx.document.querySelector('[data-github-action="disconnect"]')); + await ctx.click({ selector: '[data-github-action="disconnect"]' }); + await ctx.waitFor(() => ctx.document.querySelector('[data-card="github-connection"] output')); + }, +}); + export default settingsPageEntry({ id: "pages.settings", anchors: [{ selector: "[data-settings-search]" }, { selector: '[data-section-link="general"]' }, ...resetAnchors],