diff --git a/Packages/Sources/RxCodeCore/CLISession/CLILineToBlocksMapper.swift b/Packages/Sources/RxCodeCore/CLISession/CLILineToBlocksMapper.swift index 08d5d3ef..44b8db36 100644 --- a/Packages/Sources/RxCodeCore/CLISession/CLILineToBlocksMapper.swift +++ b/Packages/Sources/RxCodeCore/CLISession/CLILineToBlocksMapper.swift @@ -129,6 +129,8 @@ public enum CLILineToBlocksMapper { switch part { case .text(let t): guard !t.isEmpty else { continue } + let trimmed = t.trimmingCharacters(in: .whitespacesAndNewlines) + if CLIMetaEnvelope.isNoResponseRequested(trimmed) { continue } blocks.append(.text(t)) case .toolUse(let id, let name, let input): blocks.append(.toolCall(ToolCall(id: id, name: name, input: input))) diff --git a/Packages/Sources/RxCodeCore/Models/AutopilotModels.swift b/Packages/Sources/RxCodeCore/Models/AutopilotModels.swift new file mode 100644 index 00000000..8bfd70ea --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/AutopilotModels.swift @@ -0,0 +1,140 @@ +import Foundation + +// MARK: - autopilot Repo DTO + +/// Repository payload returned by `GET /api/v1/repos` on autopilot +/// (deployed at https://autopilot.rxlab.app). The macOS app no longer talks +/// to api.github.com directly — autopilot fans out across the signed-in +/// user's GitHub App installations and hands back a deduped list. +public struct AutopilotRepo: Identifiable, Codable, Sendable, Hashable { + public let id: Int + public let fullName: String + public let name: String + public let owner: String + public let isPrivate: Bool + public let defaultBranch: String? + public let htmlUrl: String + public let cloneUrl: String + public let sshUrl: String + public let updatedAt: String? + public let description: String? + + public init( + id: Int, + fullName: String, + name: String, + owner: String, + isPrivate: Bool, + defaultBranch: String?, + htmlUrl: String, + cloneUrl: String, + sshUrl: String, + updatedAt: String?, + description: String? + ) { + self.id = id + self.fullName = fullName + self.name = name + self.owner = owner + self.isPrivate = isPrivate + self.defaultBranch = defaultBranch + self.htmlUrl = htmlUrl + self.cloneUrl = cloneUrl + self.sshUrl = sshUrl + self.updatedAt = updatedAt + self.description = description + } + + private enum CodingKeys: String, CodingKey { + case id + case fullName + case name + case owner + case isPrivate = "private" + case defaultBranch + case htmlUrl + case cloneUrl + case sshUrl + case updatedAt + case description + } +} + +public struct AutopilotRepoListResponse: Codable, Sendable { + public let items: [AutopilotRepo] + public let pagination: Pagination? + + public struct Pagination: Codable, Sendable { + public let nextCursor: String? + public let hasMore: Bool + + public init(nextCursor: String?, hasMore: Bool) { + self.nextCursor = nextCursor + self.hasMore = hasMore + } + } + + public init(items: [AutopilotRepo], pagination: Pagination?) { + self.items = items + self.pagination = pagination + } +} + +// MARK: - GitHub App Installation DTOs + +/// A GitHub App installation owned by the signed-in user, as recorded by +/// autopilot. The macOS app uses this to decide whether to surface the +/// "Install GitHub App" CTA in the import-repo sheet. +public struct AutopilotInstallation: Identifiable, Codable, Sendable, Hashable { + public let id: String + public let installationId: Int + public let accountLogin: String + public let accountType: String + public let accountAvatarUrl: String? + public let repositorySelection: String + + public init( + id: String, + installationId: Int, + accountLogin: String, + accountType: String, + accountAvatarUrl: String?, + repositorySelection: String + ) { + self.id = id + self.installationId = installationId + self.accountLogin = accountLogin + self.accountType = accountType + self.accountAvatarUrl = accountAvatarUrl + self.repositorySelection = repositorySelection + } +} + +/// Cheap "do you have any installations?" probe returned by +/// `GET /api/v1/installations/precheck`. +public struct AutopilotPrecheckResponse: Codable, Sendable { + public let hasInstallation: Bool + public let count: Int + + public init(hasInstallation: Bool, count: Int) { + self.hasInstallation = hasInstallation + self.count = count + } +} + +/// Reply from `GET /api/v1/installations/install-url`. The URL embeds a +/// signed `state` JWT tying the GitHub callback back to the requesting +/// rxauth user, so the install completes correctly even if the browser +/// is not signed in to autopilot.rxlab.app. +public struct AutopilotInstallUrlResponse: Codable, Sendable { + public let url: String + public let state: String + public let expiresAt: String + + public init(url: String, state: String, expiresAt: String) { + self.url = url + self.state = state + self.expiresAt = expiresAt + } +} + diff --git a/Packages/Sources/RxCodeCore/Models/GitHubModels.swift b/Packages/Sources/RxCodeCore/Models/GitHubModels.swift deleted file mode 100644 index 965cb177..00000000 --- a/Packages/Sources/RxCodeCore/Models/GitHubModels.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Foundation - -// MARK: - GitHub User - -public struct GitHubUser: Codable, Sendable { - public let login: String - public let name: String? - public let avatarUrl: String - - public init(login: String, name: String?, avatarUrl: String) { - self.login = login - self.name = name - self.avatarUrl = avatarUrl - } - - private enum CodingKeys: String, CodingKey { - case login - case name - case avatarUrl = "avatar_url" - } -} - -// MARK: - GitHub Repo - -public struct GitHubRepo: Identifiable, Codable, Sendable { - public let id: Int - public let fullName: String - public let name: String - public let owner: Owner - public let isPrivate: Bool - public let htmlUrl: String - - public struct Owner: Codable, Sendable { - public let login: String - - public init(login: String) { - self.login = login - } - } - - public init(id: Int, fullName: String, name: String, owner: Owner, isPrivate: Bool, htmlUrl: String) { - self.id = id - self.fullName = fullName - self.name = name - self.owner = owner - self.isPrivate = isPrivate - self.htmlUrl = htmlUrl - } - - private enum CodingKeys: String, CodingKey { - case id - case fullName = "full_name" - case name - case owner - case isPrivate = "private" - case htmlUrl = "html_url" - } -} - -// MARK: - Device Flow: Device Code Response - -public struct DeviceCodeResponse: Codable, Sendable { - public let deviceCode: String - public let userCode: String - public let verificationUri: String - public let expiresIn: Int - public let interval: Int - - public init(deviceCode: String, userCode: String, verificationUri: String, expiresIn: Int, interval: Int) { - self.deviceCode = deviceCode - self.userCode = userCode - self.verificationUri = verificationUri - self.expiresIn = expiresIn - self.interval = interval - } - - private enum CodingKeys: String, CodingKey { - case deviceCode = "device_code" - case userCode = "user_code" - case verificationUri = "verification_uri" - case expiresIn = "expires_in" - case interval - } -} - -// MARK: - Custom Git Repository - -public struct CustomRepo: Identifiable, Codable, Sendable, Hashable { - public let id: UUID - public var name: String - public var cloneURL: String - - public init(id: UUID = UUID(), name: String, cloneURL: String) { - self.id = id - self.name = name - self.cloneURL = cloneURL - } -} - -// MARK: - Device Flow: Access Token Response - -public struct AccessTokenResponse: Codable, Sendable { - public let accessToken: String - public let tokenType: String - public let scope: String? - - public init(accessToken: String, tokenType: String, scope: String?) { - self.accessToken = accessToken - self.tokenType = tokenType - self.scope = scope - } - - private enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case tokenType = "token_type" - case scope - } -} diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 9f12b8bf..107a9c0d 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -10,11 +10,11 @@ 101010102FCB100000000002 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010102FCB100000000001 /* AppStateTests.swift */; }; 101010112FCB100000000002 /* BranchBriefingPromptContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010112FCB100000000001 /* BranchBriefingPromptContextTests.swift */; }; 33993F0F87CF4DB09F2813A8 /* AppStateProjectSwitchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */; }; - 7A5C0001000000000000A002 /* MockAgentBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5C0001000000000000A001 /* MockAgentBackend.swift */; }; - 7A5C0002000000000000A002 /* CrossProjectSendConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5C0002000000000000A001 /* CrossProjectSendConcurrencyTests.swift */; }; 5C2222222FCB200000000002 /* BriefingThreadRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */; }; 5C3333332FCB400000000002 /* ThreadStoreThreadSummaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3333332FCB400000000001 /* ThreadStoreThreadSummaryTests.swift */; }; 6E17B0012FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */; }; + 7A5C0001000000000000A002 /* MockAgentBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5C0001000000000000A001 /* MockAgentBackend.swift */; }; + 7A5C0002000000000000A002 /* CrossProjectSendConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5C0002000000000000A001 /* CrossProjectSendConcurrencyTests.swift */; }; 92E180F9B311F3C72D5DE6B7 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A9993BB72A5307039A88B729 /* Cocoa.framework */; }; DCA8CF4A05C959C4A6EB391F /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = CCDF9594876099576D4FD46E /* RxCodeCore */; }; DF06CCD12FB4CAB5005991E1 /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = DF06CCD02FB4CAB5005991E1 /* ViewInspector */; }; @@ -34,6 +34,12 @@ DF23F7362FB8C3EC008929A6 /* icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DF23F7352FB8C3EC008929A6 /* icon.icon */; }; DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */; }; DF31D3DC2FBC8143005D518E /* icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DF23F7352FB8C3EC008929A6 /* icon.icon */; }; + DF4628922FC611E6002D9562 /* RxAuthSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DF4628912FC611E6002D9562 /* RxAuthSwift */; }; + DF4628942FC611E6002D9562 /* RxAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = DF4628932FC611E6002D9562 /* RxAuthSwiftUI */; }; + DF462A602FC6EDCE002D9562 /* RxAuthSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DF462A5F2FC6EDCE002D9562 /* RxAuthSwift */; }; + DF462A622FC6EDCE002D9562 /* RxAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = DF462A612FC6EDCE002D9562 /* RxAuthSwiftUI */; }; + DF462DC92FC73FA8002D9562 /* RxAuthSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DF462DC82FC73FA8002D9562 /* RxAuthSwift */; }; + DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = DF462DCA2FC73FA8002D9562 /* RxAuthSwiftUI */; }; DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; }; DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; }; DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; }; @@ -114,14 +120,14 @@ 101010102FCB100000000001 /* AppStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = ""; }; 101010112FCB100000000001 /* BranchBriefingPromptContextTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BranchBriefingPromptContextTests.swift; sourceTree = ""; }; 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppStateProjectSwitchTests.swift; sourceTree = ""; }; - 7A5C0001000000000000A001 /* MockAgentBackend.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockAgentBackend.swift; sourceTree = ""; }; - 7A5C0002000000000000A001 /* CrossProjectSendConcurrencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CrossProjectSendConcurrencyTests.swift; sourceTree = ""; }; 5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BriefingThreadRowTests.swift; sourceTree = ""; }; 5C3333332FCB400000000001 /* ThreadStoreThreadSummaryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThreadStoreThreadSummaryTests.swift; sourceTree = ""; }; 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAIProviderAcceptanceTests.swift; sourceTree = ""; }; 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6E17B00D2FC8000100A10001 /* UITestplan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITestplan.xctestplan; sourceTree = ""; }; 7321B5E8B81AAB1A2DC0593B /* RxCodeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7A5C0001000000000000A001 /* MockAgentBackend.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockAgentBackend.swift; sourceTree = ""; }; + 7A5C0002000000000000A001 /* CrossProjectSendConcurrencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CrossProjectSendConcurrencyTests.swift; sourceTree = ""; }; A9993BB72A5307039A88B729 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UnitTestPlan.xctestplan; sourceTree = ""; }; DF22D8262FBE025C00E3ABFD /* RxCodeWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RxCodeWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -288,12 +294,18 @@ buildActionMask = 2147483647; files = ( E6821AC12F7CEE7200829FC9 /* SwiftTerm in Frameworks */, + DF4628922FC611E6002D9562 /* RxAuthSwift in Frameworks */, E6C001022F9B000100000001 /* Sparkle in Frameworks */, E6D001032FA0000100000001 /* RxCodeCore in Frameworks */, + DF462A622FC6EDCE002D9562 /* RxAuthSwiftUI in Frameworks */, E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */, + DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */, E6D001072FA0000100000001 /* RxCodeSync in Frameworks */, + DF462A602FC6EDCE002D9562 /* RxAuthSwift in Frameworks */, DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */, FB0000030000000000000001 /* FirebaseAnalytics in Frameworks */, + DF462DC92FC73FA8002D9562 /* RxAuthSwift in Frameworks */, + DF4628942FC611E6002D9562 /* RxAuthSwiftUI in Frameworks */, FB0000040000000000000001 /* FirebaseCrashlytics in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -578,6 +590,12 @@ DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */, FB0000010000000000000001 /* FirebaseAnalytics */, FB0000020000000000000001 /* FirebaseCrashlytics */, + DF4628912FC611E6002D9562 /* RxAuthSwift */, + DF4628932FC611E6002D9562 /* RxAuthSwiftUI */, + DF462A5F2FC6EDCE002D9562 /* RxAuthSwift */, + DF462A612FC6EDCE002D9562 /* RxAuthSwiftUI */, + DF462DC82FC73FA8002D9562 /* RxAuthSwift */, + DF462DCA2FC73FA8002D9562 /* RxAuthSwiftUI */, ); productName = RxCode; productReference = E67335382F7356F600FD26C7 /* RxCode.app */; @@ -612,6 +630,11 @@ }; E67335372F7356F600FD26C7 = { CreatedOnToolsVersion = 26.3; + SystemCapabilities = { + com.apple.AssociatedDomains = { + enabled = 1; + }; + }; }; }; }; @@ -633,6 +656,7 @@ DF06CCCF2FB4CAB5005991E1 /* XCRemoteSwiftPackageReference "ViewInspector" */, DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */, FB0000000000000000000001 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + DF462DC72FC73FA8002D9562 /* XCRemoteSwiftPackageReference "RxAuthSwift" */, ); preferredProjectObjectVersion = 77; productRefGroup = E67335392F7356F600FD26C7 /* Products */; @@ -1298,6 +1322,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = icon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RxCode/RxCode.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 24; @@ -1332,6 +1357,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = icon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RxCode/RxCode.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 24; @@ -1471,6 +1497,14 @@ minimumVersion = 1.1.0; }; }; + DF462DC72FC73FA8002D9562 /* XCRemoteSwiftPackageReference "RxAuthSwift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/rxtech-lab/RxAuthSwift"; + requirement = { + branch = main; + kind = branch; + }; + }; E6A001002F8A000100000001 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git"; @@ -1547,6 +1581,32 @@ package = DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */; productName = WaterfallGrid; }; + DF4628912FC611E6002D9562 /* RxAuthSwift */ = { + isa = XCSwiftPackageProductDependency; + productName = RxAuthSwift; + }; + DF4628932FC611E6002D9562 /* RxAuthSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + productName = RxAuthSwiftUI; + }; + DF462A5F2FC6EDCE002D9562 /* RxAuthSwift */ = { + isa = XCSwiftPackageProductDependency; + productName = RxAuthSwift; + }; + DF462A612FC6EDCE002D9562 /* RxAuthSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + productName = RxAuthSwiftUI; + }; + DF462DC82FC73FA8002D9562 /* RxAuthSwift */ = { + isa = XCSwiftPackageProductDependency; + package = DF462DC72FC73FA8002D9562 /* XCRemoteSwiftPackageReference "RxAuthSwift" */; + productName = RxAuthSwift; + }; + DF462DCA2FC73FA8002D9562 /* RxAuthSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = DF462DC72FC73FA8002D9562 /* XCRemoteSwiftPackageReference "RxAuthSwift" */; + productName = RxAuthSwiftUI; + }; DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */ = { isa = XCSwiftPackageProductDependency; productName = RxCodeChatKit; diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6d011f92..2ee77697 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ad33b5f1440a056884e1b20c3f3f5d42f2cfe1032060bcf21e0a004535c018c2", + "originHash" : "2a25e60d59e012004c2de1aa5bf8f77cbe5787aebfadc280bb28549e57ca69a1", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -127,6 +127,15 @@ "version" : "2.4.0" } }, + { + "identity" : "rxauthswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rxtech-lab/RxAuthSwift", + "state" : { + "branch" : "main", + "revision" : "31e20effc3215c7656ec1543a27a1dcc2a9b0ccf" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -163,6 +172,15 @@ "version" : "1.3.2" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "7dc6101ae4dbe95cd3bc9cebad3b7cf8e49a7a63", + "version" : "1.13.0" + } + }, { "identity" : "swift-markdown-ui", "kind" : "remoteSourceControl", diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index 46a86416..3d49a147 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -161,6 +161,26 @@ extension AppState { } } + /// Drop "No response requested." text blocks from the assistant message + /// at `idx`. The marker is the model's response when a turn arrives + /// without a user prompt (ScheduleWakeup, hook re-entry) and reads as + /// noise in the chat UI. + /// + /// `removeIfEmpty` controls whether a message left with no blocks is also + /// deleted. The normal stream path passes `true` to discard pure no-op + /// envelopes; the cancel path passes `false` so pausing a turn never makes + /// the partial assistant bubble disappear. + static func stripNoOpText(at idx: Int, in messages: inout [ChatMessage], removeIfEmpty: Bool = true) { + guard messages.indices.contains(idx) else { return } + messages[idx].blocks.removeAll { block in + guard let text = block.text else { return false } + return CLIMetaEnvelope.isNoResponseRequested(text.trimmingCharacters(in: .whitespacesAndNewlines)) + } + if removeIfEmpty, messages[idx].blocks.isEmpty { + messages.remove(at: idx) + } + } + /// Wrap a branch briefing into a system-prompt section the agent can use as /// background context. The briefing is auto-generated from earlier threads, /// so it is framed as advisory rather than authoritative. @@ -670,6 +690,7 @@ extension AppState { }) { state.messages[idx].isStreaming = false state.messages[idx].finalizeToolCalls() + Self.stripNoOpText(at: idx, in: &state.messages) } state.messages.append(ChatMessage(role: .assistant, isStreaming: true)) state.needsNewMessage = false diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index bf2df82d..c7dbb625 100644 --- a/RxCode/App/AppState+Lifecycle.swift +++ b/RxCode/App/AppState+Lifecycle.swift @@ -155,13 +155,32 @@ extension AppState { try? await persistence.saveProjects(projects) } - if let cachedUser = await persistence.loadGitHubUser() { - gitHubUser = cachedUser - isLoggedIn = true - _ = await github.loadToken() + // Restore an existing rxauth session (token refresh runs silently). + // One-time migration: purge the legacy GitHub device-flow access token + // from the old `com.claudework.github` keychain entry so it never + // gets re-used. `try?` — failure (no entry present) is the happy path. + try? KeychainHelper.delete(service: "com.claudework.github", account: "access_token") + // Restore an existing rxauth session. `OAuthManager.checkExistingAuth` + // refreshes the access token if it has expired and starts its own + // 5-minute refresh timer, so no extra scheduling is needed here. + await rxAuth.restore() + if isSignedIn { + Task { [weak self] in await self?.loadRepos() } + } + + // React to RxAuthSwift session expiry by clearing autopilot repos. + // `isSignedIn`/`rxUser` are computed from the manager, so they update + // automatically when `OAuthManager` flips to `.unauthenticated`. + NotificationCenter.default.addObserver( + forName: .rxAuthSessionExpired, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.repos = [] + } } - customRepos = await persistence.loadCustomRepos() marketplaceCustomSources = await marketplace.customSources() seedUITestBriefingIfRequested() diff --git a/RxCode/App/AppState+Messaging.swift b/RxCode/App/AppState+Messaging.swift index 74fd6870..46161a15 100644 --- a/RxCode/App/AppState+Messaging.swift +++ b/RxCode/App/AppState+Messaging.swift @@ -456,6 +456,7 @@ extension AppState { if let start = state.streamingStartDate { state.messages[idx].duration = Date().timeIntervalSince(start) } + Self.stripNoOpText(at: idx, in: &state.messages) } state.streamingStartDate = nil } diff --git a/RxCode/App/AppState+Project.swift b/RxCode/App/AppState+Project.swift index 92a0c440..7aedc7ad 100644 --- a/RxCode/App/AppState+Project.swift +++ b/RxCode/App/AppState+Project.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import os import RxCodeChatKit @@ -230,46 +231,150 @@ extension AppState { await didSwitchToSession(session) } - // MARK: - GitHub + // MARK: - rxauth + autopilot - func loginToGitHub() async throws -> DeviceCodeResponse { - try await github.startDeviceFlow() - } - - func completeGitHubLogin(deviceCode: String, interval: Int) async throws { - _ = try await github.pollForToken(deviceCode: deviceCode, interval: interval) - - let user = try await github.fetchUser() - gitHubUser = user - isLoggedIn = true + /// Called from `RxAuthSignInView.onAuthSuccess` once `OAuthManager` has + /// switched to `.authenticated`. `isSignedIn`/`rxUser` track the manager + /// directly; this hook only handles the side effects (mark onboarding + /// complete, kick off a background repo fetch). + func onRxAuthSignedIn() { onboardingCompleted = true UserDefaults.standard.set(true, forKey: "onboardingCompleted") + Task { await loadRepos() } + } - do { try await persistence.saveGitHubUser(user) } - catch { logger.error("Failed to cache GitHub user: \(error.localizedDescription)") } + func signOutRxAuth() async { + await rxAuth.signOut() + repos = [] + installations = [] + hasGitHubAppInstalled = nil + repoNextCursor = nil + repoHasMoreRepos = false + repoCurrentSearch = "" + } + /// Default page size for the server-side paginated repo list. + static let defaultRepoPageSize = 50 + + /// Load the first page for the given search term, plus the parallel + /// installation list used to render owner avatars in the import-repo + /// sheet. Replaces any previously loaded repos. Pass `refresh: true` to + /// bust the server's 60s cache on the repos endpoint (installations + /// always refetch). + func loadRepos(search: String = "", refresh: Bool = false) async { + guard isSignedIn else { return } + let trimmed = search.trimmingCharacters(in: .whitespacesAndNewlines) + repoCurrentSearch = trimmed + let requestedSearch = trimmed + isLoadingRepos = true + defer { isLoadingRepos = false } + async let reposTask = autopilot.listRepositories( + search: trimmed.isEmpty ? nil : trimmed, + cursor: nil, + perPage: AppState.defaultRepoPageSize, + refresh: refresh + ) + async let installationsTask = autopilot.listInstallations() do { - let publicKey = try await github.setupSSH() - try await github.registerSSHKey(publicKey) + let response = try await reposTask + // A newer search may have replaced `repoCurrentSearch` while this + // request was in flight — drop the stale result so the UI doesn't + // briefly show repos for the previous query. + guard requestedSearch == repoCurrentSearch else { return } + repos = response.items + repoNextCursor = response.pagination?.nextCursor + repoHasMoreRepos = response.pagination?.hasMore ?? false } catch { - logger.warning("SSH setup failed: \(error.localizedDescription)") + logger.error("Failed to fetch repos from autopilot: \(error.localizedDescription)") + } + do { + installations = try await installationsTask + } catch { + // Avatars degrade gracefully — log and continue with the existing + // list rather than failing the whole load. + logger.error("Failed to fetch installations from autopilot: \(error.localizedDescription)") } } - func skipGitHubLogin() { - onboardingCompleted = true - UserDefaults.standard.set(true, forKey: "onboardingCompleted") + /// Fetch the next page for the currently displayed search term. No-op when + /// the server reported no more pages, a load is already in flight, or the + /// user is signed out. + func loadMoreRepos() async { + guard isSignedIn, !isLoadingRepos, !isLoadingMoreRepos else { return } + guard repoHasMoreRepos, let cursor = repoNextCursor else { return } + let requestedSearch = repoCurrentSearch + isLoadingMoreRepos = true + defer { isLoadingMoreRepos = false } + do { + let response = try await autopilot.listRepositories( + search: requestedSearch.isEmpty ? nil : requestedSearch, + cursor: cursor, + perPage: AppState.defaultRepoPageSize + ) + // Drop the page if the search changed while we were loading. + guard requestedSearch == repoCurrentSearch else { return } + // Defensive: server returns deduped pages, but if the user kicked + // a refresh between pages we could see overlap — dedupe by id. + let existingIds = Set(repos.map(\.id)) + repos.append(contentsOf: response.items.filter { !existingIds.contains($0.id) }) + repoNextCursor = response.pagination?.nextCursor + repoHasMoreRepos = response.pagination?.hasMore ?? false + } catch { + logger.error("Failed to fetch next repo page from autopilot: \(error.localizedDescription)") + } + } + + /// Tiny "do you have any installations?" probe. Stored on AppState so + /// the sheet can branch on it without holding its own state machine. + func checkInstallation() async { + guard isSignedIn else { + hasGitHubAppInstalled = nil + return + } + isCheckingInstall = true + defer { isCheckingInstall = false } + do { + let result = try await autopilot.precheckInstallations() + hasGitHubAppInstalled = result.hasInstallation + } catch { + logger.error("Failed to precheck installations: \(error.localizedDescription)") + } } + /// Fetch a fresh GitHub App install URL (with signed state) and open it + /// in the user's default browser. The sheet's Refresh button is then + /// responsible for picking up the resulting installation. + func openInstallGitHubApp() async { + guard isSignedIn else { return } + isOpeningInstallUrl = true + defer { isOpeningInstallUrl = false } + do { + let response = try await autopilot.installUrl() + guard let url = URL(string: response.url) else { + logger.error("autopilot returned an invalid install URL: \(response.url)") + return + } + NSWorkspace.shared.open(url) + } catch { + logger.error("Failed to fetch install URL: \(error.localizedDescription)") + } + } - func fetchRepos() async { - isFetchingRepos = true - defer { isFetchingRepos = false } - do { repos = try await github.fetchRepos() } - catch { logger.error("Failed to fetch repos: \(error.localizedDescription)") } + /// User-triggered "did the install land?" refresh. Re-runs the precheck + /// and, if installed, follows up with a forced repo refresh so the new + /// installation's repos show up immediately. + func refreshInstallationAndRepos() async { + await checkInstallation() + if hasGitHubAppInstalled == true { + await loadRepos(search: repoCurrentSearch, refresh: true) + } else { + repos = [] + repoNextCursor = nil + repoHasMoreRepos = false + } } - func cloneAndAddProject(_ repo: GitHubRepo, in window: WindowState) async throws { + func cloneAndAddProject(_ repo: AutopilotRepo, in window: WindowState) async throws { let home = FileManager.default.homeDirectoryForCurrentUser.path let clonePath = "\(home)/RxCode/\(repo.name)" let parentDir = "\(home)/RxCode" @@ -277,52 +382,29 @@ extension AppState { if !fm.fileExists(atPath: parentDir) { try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true) } - try await github.cloneRepo(repo, to: clonePath) + let cloneURL = repo.isPrivate ? repo.sshUrl : repo.cloneUrl + try await gitClone(from: cloneURL, to: clonePath) await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: repo.fullName, in: window) } - func loadCustomRepos() async { - customRepos = await persistence.loadCustomRepos() - } + private func gitClone(from url: String, to path: String) async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["clone", url, path] + process.environment = ProcessInfo.processInfo.environment - func addCustomRepo(url: String, name: String, in window: WindowState) async throws { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let clonePath = "\(home)/RxCode/\(name)" - let fm = FileManager.default - if !fm.fileExists(atPath: "\(home)/RxCode") { - try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) - } - if fm.fileExists(atPath: clonePath) { - throw NSError(domain: "RxCode", code: 1, userInfo: [NSLocalizedDescriptionKey: "A folder named '\(name)' already exists in ~/RxCode"]) - } - try await github.cloneRepo(from: url, to: clonePath) - let repo = CustomRepo(name: name, cloneURL: url) - customRepos.append(repo) - try await persistence.saveCustomRepos(customRepos) - await addAndSelectProject(name: name, path: clonePath, gitHubRepo: nil, in: window) - } + let stderrPipe = Pipe() + process.standardError = stderrPipe + try process.run() - func cloneCustomRepo(_ repo: CustomRepo, in window: WindowState) async throws { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let clonePath = "\(home)/RxCode/\(repo.name)" - let fm = FileManager.default - if !fm.fileExists(atPath: "\(home)/RxCode") { - try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in continuation.resume() } } - if fm.fileExists(atPath: clonePath) { - throw NSError(domain: "RxCode", code: 1, userInfo: [NSLocalizedDescriptionKey: "A folder named '\(repo.name)' already exists in ~/RxCode"]) - } - try await github.cloneRepo(from: repo.cloneURL, to: clonePath) - await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: nil, in: window) - } - func removeCustomRepo(_ repo: CustomRepo) async { - customRepos.removeAll { $0.id == repo.id } - do { - try await persistence.saveCustomRepos(customRepos) - } catch { - logger.error("Failed to save custom repos: \(error.localizedDescription)") + guard process.terminationStatus == 0 else { + let data = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: data, encoding: .utf8) ?? "unknown error" + throw NSError(domain: "RxCode.GitClone", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: stderr]) } } - } diff --git a/RxCode/App/AppState+Stream.swift b/RxCode/App/AppState+Stream.swift index db77646b..2ca7433f 100644 --- a/RxCode/App/AppState+Stream.swift +++ b/RxCode/App/AppState+Stream.swift @@ -220,6 +220,7 @@ extension AppState { // New Claude turn after receiving tool result — start a new ChatMessage state.messages[idx].isStreaming = false state.messages[idx].finalizeToolCalls() + Self.stripNoOpText(at: idx, in: &state.messages) state.needsNewMessage = false state.messages.append(ChatMessage(role: .assistant, content: buffered, isStreaming: true)) } else { @@ -304,6 +305,7 @@ extension AppState { if let idx = state.messages.indices.reversed().first(where: { state.messages[$0].role == .assistant && state.messages[$0].isStreaming }) { state.messages[idx].isStreaming = false state.messages[idx].finalizeToolCalls() + Self.stripNoOpText(at: idx, in: &state.messages) } state.messages.append(ChatMessage(role: .assistant, isStreaming: true)) state.needsNewMessage = false @@ -442,11 +444,13 @@ extension AppState { // The user paused this turn — keep the partial assistant bubble // visible. markStreamInterrupted() clears the message's streaming // flag and retains in-progress tool calls (flagged as interrupted) - // instead of dropping them. + // instead of dropping them; the no-op strip below is told not to + // delete an emptied message. state.messages[idx].markStreamInterrupted() if let start = state.streamingStartDate { state.messages[idx].duration = Date().timeIntervalSince(start) } + Self.stripNoOpText(at: idx, in: &state.messages, removeIfEmpty: false) } state.streamingStartDate = nil state.inFlightUserAttachments = [] diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 48669434..689bc455 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -1,5 +1,6 @@ import Foundation import os +import RxAuthSwift import RxCodeChatKit import RxCodeCore import RxCodeSync @@ -764,13 +765,42 @@ final class AppState { didSet { UserDefaults.standard.set(permissionMode.rawValue, forKey: "selectedPermissionMode") } } - // MARK: - GitHub - - var isLoggedIn = false - var gitHubUser: GitHubUser? - var repos: [GitHubRepo] = [] - var customRepos: [CustomRepo] = [] - var isCloningCustomRepo: String? + // MARK: - rxauth + autopilot + + /// Single source of truth for sign-in status. Reads through to the + /// shared `OAuthManager`, so every UI surface (toolbar sheet, settings + /// tab, sidebar) stays in sync regardless of which one triggered the + /// state change. `OAuthManager` is `@Observable`, so SwiftUI tracks + /// these reads transitively. + var isSignedIn: Bool { rxAuth.manager.authState == .authenticated } + var rxUser: User? { rxAuth.manager.currentUser } + var repos: [AutopilotRepo] = [] + var isLoadingRepos = false + /// True while a `loadMoreRepos()` call is in flight. Separate from + /// `isLoadingRepos` so the list keeps rendering existing rows while a + /// follow-on page is fetched and only the footer shows a spinner. + var isLoadingMoreRepos = false + /// Cursor returned by the server for the next page, or `nil` when the + /// last page has been consumed. + var repoNextCursor: String? + /// `true` while the server reports more pages are available for the + /// current `(search, refresh)` request. + var repoHasMoreRepos = false + /// Search term used for the currently loaded page set. Tracked so + /// out-of-order responses from a stale query can be ignored. + var repoCurrentSearch: String = "" + + /// `nil` = unknown (not yet probed), `true`/`false` = result of the last + /// `autopilot.precheckInstallations()` call. Drives the + /// "Install GitHub App" empty state in `AutopilotRepoSheet`. + var hasGitHubAppInstalled: Bool? + var isCheckingInstall = false + var isOpeningInstallUrl = false + + /// GitHub App installations owned by the signed-in user. Used to look up + /// owner avatars in the import-repo sheet so each row gets a recognizable + /// glyph instead of a generic icon. + var installations: [AutopilotInstallation] = [] // MARK: - CLI Version @@ -803,7 +833,8 @@ final class AppState { // MARK: - Services - let github = GitHubService() + let rxAuth = RxAuthService.shared + let autopilot: AutopilotService let permission = PermissionServer() let metaStore = SessionMetaStore() let cliStore: CLISessionStore @@ -929,6 +960,7 @@ final class AppState { self.persistence = injectedPersistence ?? PersistenceService(metaStore: metaStore, cliStore: cliStore) self.mcp = MCPService(claudeService: claude) self.threadStore = ThreadStore.make() + self.autopilot = AutopilotService(rxAuth: RxAuthService.shared) self.runService.onTasksChanged = { [weak self] in Task { @MainActor [weak self] in self?.broadcastMobileRunTasks() @@ -978,7 +1010,6 @@ final class AppState { /// threads in memory. Live (streaming) sessions bypass this entirely. var mobileFullMessageCache: (sessionID: String, messages: [ChatMessage])? - var isFetchingRepos = false /// Last seen jsonl byte size per session — used as a cheap drift signal /// in `reconcileFromDisk` so the no-drift path skips the full mmap+parse. diff --git a/RxCode/Info.plist b/RxCode/Info.plist index 6df42d6d..ea3e4656 100644 --- a/RxCode/Info.plist +++ b/RxCode/Info.plist @@ -29,6 +29,21 @@ NSAllowsLocalNetworking + CFBundleURLTypes + + + CFBundleURLName + com.rxtech.rxcode.oauth + CFBundleURLSchemes + + rxcode + + CFBundleTypeRole + Viewer + + + AutopilotBaseURL + https://autopilot.rxlab.app NSHumanReadableCopyright SUFeedURL diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 7f552e31..08d69d5e 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -75,6 +75,7 @@ } }, "@%@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -518,6 +519,7 @@ } }, "Add a Git repository by URL" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -682,6 +684,7 @@ } }, "Add Repository" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1121,6 +1124,7 @@ } }, "Authenticate on GitHub" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1143,6 +1147,7 @@ } }, "Authentication Code" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1301,6 +1306,9 @@ } } } + }, + "Autopilot" : { + }, "Awaiting check" : { "localizations" : { @@ -1645,6 +1653,7 @@ } }, "Clone" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1655,6 +1664,7 @@ } }, "Clone & Add" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1799,6 +1809,7 @@ } }, "Configure org access →" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1821,6 +1832,7 @@ } }, "Connect GitHub" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1843,6 +1855,7 @@ } }, "Connect GitHub to\nimport repos instantly" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1865,6 +1878,7 @@ } }, "Connect GitHub to fetch your repo list and add projects with a single click." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1915,6 +1929,9 @@ } } } + }, + "Connect your rxlab account to enable autopilot." : { + }, "Connected" : { "localizations" : { @@ -2024,6 +2041,7 @@ } }, "Copy Code" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2106,6 +2124,7 @@ } }, "Custom" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -2512,6 +2531,7 @@ } }, "Don't see your org repos?" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2534,6 +2554,7 @@ } }, "Don't see your org repos? →" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3022,6 +3043,9 @@ } } } + }, + "Finished installing? Tap Refresh." : { + }, "Focus Mode" : { "localizations" : { @@ -3206,6 +3230,7 @@ } }, "Git Repositories" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3216,6 +3241,7 @@ } }, "Git URL" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3370,6 +3396,7 @@ } }, "https://github.com/owner/repo.git or git@github.com:owner/repo.git" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3400,6 +3427,9 @@ } } } + }, + "Import Repository" : { + }, "In progress" : { "localizations" : { @@ -3478,6 +3508,15 @@ } } } + }, + "Install GitHub App" : { + + }, + "Install GitHub App on another account" : { + + }, + "Install the GitHub App" : { + }, "Installed" : { "localizations" : { @@ -3636,6 +3675,9 @@ } } } + }, + "Loading more..." : { + }, "Loading registry…" : { "localizations" : { @@ -3816,6 +3858,7 @@ } }, "Manage GitHub Repos" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4103,6 +4146,7 @@ } }, "my-project" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4309,6 +4353,7 @@ } }, "No custom repositories" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4493,6 +4538,9 @@ } } } + }, + "No repos match “%@”" : { + }, "No results for '%@'" : { "localizations" : { @@ -4619,6 +4667,9 @@ } } } + }, + "Not signed in" : { + }, "Notifications" : { "localizations" : { @@ -5107,6 +5158,9 @@ } } } + }, + "Private" : { + }, "Project" : { "localizations" : { @@ -5151,6 +5205,7 @@ } }, "Project Name" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -5221,6 +5276,9 @@ } } } + }, + "Public" : { + }, "Push" : { "localizations" : { @@ -5783,6 +5841,9 @@ } } } + }, + "RxCode needs the GitHub App to see your repositories. Install it for the account or organizations you want to import from." : { + }, "RxCode.xcodeproj" : { "localizations" : { @@ -6481,8 +6542,21 @@ } } } + }, + "Sign In" : { + + }, + "Sign in to rxlab" : { + + }, + "Sign in to rxlab to\nimport GitHub repos" : { + + }, + "Sign in to rxlab to\nimport your GitHub repos" : { + }, "Sign in with GitHub" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -6503,6 +6577,18 @@ } } } + }, + "Sign in with rxlab" : { + + }, + "Sign in with rxlab to import GitHub repositories and use autopilot features." : { + + }, + "Sign in with rxlab to import GitHub repositories." : { + + }, + "Sign Out" : { + }, "sk-..." : { "localizations" : { @@ -6569,6 +6655,7 @@ } }, "Source" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -7227,6 +7314,7 @@ } }, "Waiting for authentication..." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -7247,6 +7335,9 @@ } } } + }, + "Welcome to RxCode" : { + }, "Wipe cached embeddings and re-embed every thread for semantic search. Use this if global search results look stale or empty." : { "localizations" : { diff --git a/RxCode/RxCode.entitlements b/RxCode/RxCode.entitlements new file mode 100644 index 00000000..1de7656b --- /dev/null +++ b/RxCode/RxCode.entitlements @@ -0,0 +1,13 @@ + + + + + com.apple.security.app-sandbox + + com.apple.developer.associated-domains + + webcredentials:rxlab.app + webcredentials:rxlab.app?mode=developer + + + diff --git a/RxCode/Services/AutopilotService.swift b/RxCode/Services/AutopilotService.swift new file mode 100644 index 00000000..6268aed5 --- /dev/null +++ b/RxCode/Services/AutopilotService.swift @@ -0,0 +1,170 @@ +import Foundation +import RxCodeCore +import os + +/// Talks to the autopilot service (deployed at `https://autopilot.rxlab.app`) +/// using the rxauth bearer obtained from `RxAuthService`. autopilot holds the +/// GitHub App installation tokens, so the macOS app never sees a GitHub +/// OAuth token. +@MainActor +final class AutopilotService { + + enum ServiceError: LocalizedError { + case notAuthenticated + case invalidResponse + case apiError(Int, String) + case decodingError(String) + + var errorDescription: String? { + switch self { + case .notAuthenticated: + return "Not signed in. Please sign in with rxlab." + case .invalidResponse: + return "Received an invalid response from autopilot." + case .apiError(let code, let detail): + return "autopilot error (\(code)): \(detail)" + case .decodingError(let detail): + return "Failed to decode autopilot response: \(detail)" + } + } + } + + private let rxAuth: RxAuthService + private let logger = Logger(subsystem: "com.claudework", category: "AutopilotService") + private let session: URLSession = .shared + + init(rxAuth: RxAuthService) { + self.rxAuth = rxAuth + } + + var baseURL: URL { + let override = Bundle.main.object(forInfoDictionaryKey: "AutopilotBaseURL") as? String + if let override, !override.isEmpty, let url = URL(string: override) { + return url + } + return URL(string: "https://autopilot.rxlab.app")! + } + + /// `GET /api/v1/repos` — live, paginated list of GitHub repos accessible + /// via the signed-in user's GitHub App installations. Server caches the + /// full deduped list for 60s; pagination + search filter happen on top of + /// that cached snapshot, so passing `search` does not trigger a fresh + /// fan-out to GitHub. + func listRepositories( + search: String? = nil, + cursor: String? = nil, + perPage: Int? = nil, + refresh: Bool = false + ) async throws -> AutopilotRepoListResponse { + var components = URLComponents(url: baseURL.appendingPathComponent("/api/v1/repos"), resolvingAgainstBaseURL: false) + var items: [URLQueryItem] = [] + if refresh { + items.append(URLQueryItem(name: "refresh", value: "1")) + } + if let cursor, !cursor.isEmpty { + items.append(URLQueryItem(name: "cursor", value: cursor)) + } + if let perPage { + items.append(URLQueryItem(name: "perPage", value: String(perPage))) + } + if let trimmed = search?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty { + items.append(URLQueryItem(name: "search", value: trimmed)) + } + if !items.isEmpty { + components?.queryItems = items + } + guard let url = components?.url else { + throw ServiceError.invalidResponse + } + return try await get(url: url) + } + + /// `GET /api/v1/installations/precheck` — tiny probe used to decide + /// whether to surface the "Install GitHub App" CTA before paying the + /// full installation/repo fetch cost. + func precheckInstallations() async throws -> AutopilotPrecheckResponse { + let url = baseURL.appendingPathComponent("/api/v1/installations/precheck") + return try await get(url: url) + } + + /// `GET /api/v1/installations` — full list of GitHub App installations + /// owned by the signed-in user. + func listInstallations() async throws -> [AutopilotInstallation] { + let url = baseURL.appendingPathComponent("/api/v1/installations") + return try await get(url: url) + } + + /// `GET /api/v1/installations/install-url` — returns a GitHub App + /// install URL with a signed `state` JWT so the callback can attribute + /// the new installation back to the rxauth user that initiated it. + func installUrl() async throws -> AutopilotInstallUrlResponse { + let url = baseURL.appendingPathComponent("/api/v1/installations/install-url") + return try await get(url: url) + } + + // MARK: - Internal + + private func get(url: URL) async throws -> T { + try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + } + + /// Sign the request with the current bearer, hit the network, and retry + /// exactly once after a refresh if the server returned 401. A second 401 + /// posts `.rxAuthSessionExpired` so the UI can re-present sign-in. + private func performWithRetry(_ build: (String) -> URLRequest) async throws -> T { + guard let token = await rxAuth.accessToken() else { + throw ServiceError.notAuthenticated + } + + let request = build(token) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw ServiceError.invalidResponse + } + + if http.statusCode == 401 { + // One silent refresh + retry. If that fails too, surface the + // session-expired notification so the UI re-presents sign-in. + guard let refreshed = await rxAuth.accessToken() else { + NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) + throw ServiceError.notAuthenticated + } + let retried = build(refreshed) + let (data2, response2) = try await session.data(for: retried) + guard let http2 = response2 as? HTTPURLResponse else { + throw ServiceError.invalidResponse + } + if http2.statusCode == 401 { + NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) + throw ServiceError.notAuthenticated + } + return try decode(data: data2, response: http2) + } + + return try decode(data: data, response: http) + } + + private func decode(data: Data, response: HTTPURLResponse) throws -> T { + guard (200..<300).contains(response.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "no body" + throw ServiceError.apiError(response.statusCode, body) + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw ServiceError.decodingError(error.localizedDescription) + } + } +} + +extension Notification.Name { + /// Re-export RxAuthSwift's session-expired notification under the same + /// name so call sites that don't import RxAuthSwift can subscribe. + static let rxAuthSessionExpired = Notification.Name("rxAuthSessionExpired") +} diff --git a/RxCode/Services/FoundationModelSummarizationService.swift b/RxCode/Services/FoundationModelSummarizationService.swift index b03e570a..c356c2af 100644 --- a/RxCode/Services/FoundationModelSummarizationService.swift +++ b/RxCode/Services/FoundationModelSummarizationService.swift @@ -184,16 +184,153 @@ actor FoundationModelSummarizationService { private func respond(instructions: String, prompt: String) async -> String? { guard Self.isAvailable else { return nil } + return await respond(instructions: instructions, prompt: prompt, allowRollingWindow: true) + } + + private func respond( + instructions: String, + prompt: String, + allowRollingWindow: Bool + ) async -> String? { do { let session = LanguageModelSession(instructions: instructions) let response = try await session.respond(to: prompt) return response.content } catch { + if allowRollingWindow, Self.isContextWindowError(error) { + logger.notice("Foundation Models context window exceeded; retrying with rolling-window compression (\(prompt.count) chars)") + return await respondWithRollingWindow(instructions: instructions, prompt: prompt) + } logger.warning("Foundation Models summarization failed: \(error.localizedDescription)") return nil } } + /// Re-runs the original (instructions, prompt) pair after compressing the + /// prompt with a rolling-window pass that builds up an accumulated summary + /// chunk-by-chunk. Each pass halves the chunk size so we converge even when + /// the model's window is very small. + private func respondWithRollingWindow( + instructions: String, + prompt: String + ) async -> String? { + let attempts = [2_500, 1_200, 600] + var workingPrompt = prompt + for chunkChars in attempts { + guard let compressed = await rollingWindowCompress( + text: workingPrompt, + chunkChars: chunkChars + ), !compressed.isEmpty else { + return nil + } + workingPrompt = compressed + do { + let session = LanguageModelSession(instructions: instructions) + let response = try await session.respond(to: workingPrompt) + return response.content + } catch { + if Self.isContextWindowError(error) { + continue + } + logger.warning("Foundation Models rolling-window retry failed: \(error.localizedDescription)") + return nil + } + } + logger.warning("Foundation Models rolling-window gave up after \(attempts.count) attempts") + return nil + } + + /// Splits `text` into chunks and folds them into a single accumulated + /// summary: `summary = compress(summary + nextChunk)`. Returns the final + /// accumulated summary (already shorter than the original). + private func rollingWindowCompress( + text: String, + chunkChars: Int + ) async -> String? { + let chunks = Self.splitIntoChunks(text: text, chunkChars: chunkChars) + guard !chunks.isEmpty else { return nil } + var accumulated = "" + for chunk in chunks { + let merged: String + if accumulated.isEmpty { + merged = chunk + } else { + merged = """ + Summary so far: + \(accumulated) + + Additional content to incorporate (preserve key facts, decisions, file paths, identifiers, and intent): + \(chunk) + """ + } + do { + let session = LanguageModelSession( + instructions: "You compress long text into a concise running summary while preserving key facts, decisions, file paths, identifiers, and intent. Output only the compressed text, no preamble." + ) + let response = try await session.respond(to: merged) + accumulated = response.content + } catch { + if Self.isContextWindowError(error), chunkChars > 400 { + guard let inner = await rollingWindowCompress( + text: chunk, + chunkChars: max(400, chunkChars / 2) + ) else { + return nil + } + accumulated = accumulated.isEmpty ? inner : "\(accumulated)\n\n\(inner)" + } else { + logger.warning("Foundation Models rolling-window compression failed: \(error.localizedDescription)") + return nil + } + } + } + return accumulated + } + + private static func isContextWindowError(_ error: Error) -> Bool { + if let generationError = error as? LanguageModelSession.GenerationError { + if case .exceededContextWindowSize = generationError { + return true + } + } + let message = error.localizedDescription.lowercased() + return message.contains("context window") + } + + private static func splitIntoChunks(text: String, chunkChars: Int) -> [String] { + guard text.count > chunkChars else { return text.isEmpty ? [] : [text] } + var chunks: [String] = [] + var current = "" + let paragraphs = text.components(separatedBy: "\n\n") + for paragraph in paragraphs { + if paragraph.count > chunkChars { + if !current.isEmpty { + chunks.append(current) + current = "" + } + var idx = paragraph.startIndex + while idx < paragraph.endIndex { + let end = paragraph.index(idx, offsetBy: chunkChars, limitedBy: paragraph.endIndex) ?? paragraph.endIndex + chunks.append(String(paragraph[idx.. chunkChars { + if !current.isEmpty { + chunks.append(current) + } + current = paragraph + } else { + current = current.isEmpty ? paragraph : "\(current)\n\n\(paragraph)" + } + } + if !current.isEmpty { + chunks.append(current) + } + return chunks + } + private func cleanTitle(_ raw: String?) -> String? { guard let raw else { return nil } let cleaned = raw diff --git a/RxCode/Services/GitHubService.swift b/RxCode/Services/GitHubService.swift deleted file mode 100644 index 8dc83af0..00000000 --- a/RxCode/Services/GitHubService.swift +++ /dev/null @@ -1,371 +0,0 @@ -import Foundation -import RxCodeCore -import os -import Security - -actor GitHubService { - - static let oauthClientId = "Ov23li0plkoiQCmLm5O5" - private let clientId = oauthClientId - private let logger = Logger(subsystem: "com.claudework", category: "GitHubService") - private let sshKeyManager = SSHKeyManager() - - private(set) var accessToken: String? - private(set) var currentUser: GitHubUser? - - // MARK: - Errors - - enum GitHubError: LocalizedError { - case noAccessToken - case deviceCodeExpired - case accessDenied - case networkError(String) - case apiError(Int, String) - case decodingError(String) - case cloneFailed(String) - case invalidResponse - - var errorDescription: String? { - switch self { - case .noAccessToken: - return "Not authenticated. Please sign in with GitHub." - case .deviceCodeExpired: - return "The device code has expired. Please restart the login process." - case .accessDenied: - return "Access was denied. Please try again and authorize the app." - case .networkError(let detail): - return "Network error: \(detail)" - case .apiError(let code, let message): - return "GitHub API error (\(code)): \(message)" - case .decodingError(let detail): - return "Failed to decode response: \(detail)" - case .cloneFailed(let detail): - return "Git clone failed: \(detail)" - case .invalidResponse: - return "Received an invalid response from GitHub." - } - } - } - - // MARK: - Keychain Constants - - private let keychainService = "com.claudework.github" - private let keychainAccount = "access_token" - - // MARK: - Device Flow OAuth - - /// Start the GitHub Device Flow by requesting a device code. - func startDeviceFlow() async throws -> DeviceCodeResponse { - let url = URL(string: "https://github.com/login/device/code")! - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body: [String: String] = [ - "client_id": clientId, - "scope": "repo,read:org" - ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 - let body = String(data: data, encoding: .utf8) ?? "unknown" - throw GitHubError.apiError(statusCode, body) - } - - do { - let decoder = JSONDecoder() - return try decoder.decode(DeviceCodeResponse.self, from: data) - } catch { - throw GitHubError.decodingError(error.localizedDescription) - } - } - - /// Poll GitHub for an access token after the user has entered their device code. - /// - /// - Parameters: - /// - deviceCode: The device code from `startDeviceFlow()`. - /// - interval: The minimum polling interval in seconds. - /// - Returns: The access token string. - func pollForToken(deviceCode: String, interval: Int) async throws -> String { - var currentInterval = interval - let maxAttempts = 60 // Max 60 attempts (~5 minutes) - var attempts = 0 - - let url = URL(string: "https://github.com/login/oauth/access_token")! - - while attempts < maxAttempts { - attempts += 1 - try await Task.sleep(nanoseconds: UInt64(currentInterval) * 1_000_000_000) - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body: [String: String] = [ - "client_id": clientId, - "device_code": deviceCode, - "grant_type": "urn:ietf:params:oauth:grant-type:device_code" - ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 - let responseBody = String(data: data, encoding: .utf8) ?? "unknown" - throw GitHubError.apiError(statusCode, responseBody) - } - - // Try to decode as a successful token response first. - if let tokenResponse = try? JSONDecoder().decode(AccessTokenResponse.self, from: data), - !tokenResponse.accessToken.isEmpty { - self.accessToken = tokenResponse.accessToken - try saveToken(tokenResponse.accessToken) - return tokenResponse.accessToken - } - - // Otherwise, check the error field for polling status. - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let errorCode = json["error"] as? String else { - throw GitHubError.invalidResponse - } - - switch errorCode { - case "authorization_pending": - // User hasn't authorized yet; keep polling. - continue - case "slow_down": - // Increase interval by 5 seconds per spec. - currentInterval += 5 - continue - case "expired_token": - throw GitHubError.deviceCodeExpired - case "access_denied": - throw GitHubError.accessDenied - default: - let description = json["error_description"] as? String ?? errorCode - throw GitHubError.apiError(0, description) - } - } - - throw GitHubError.deviceCodeExpired - } - - // MARK: - Token Management (Keychain) - - func saveToken(_ token: String) throws { - guard let tokenData = token.data(using: .utf8) else { return } - do { - try KeychainHelper.save(tokenData, service: keychainService, account: keychainAccount) - } catch { - logger.error("Keychain save failed: \(error)") - throw GitHubError.networkError("Failed to save token to Keychain") - } - self.accessToken = token - logger.info("GitHub token saved to Keychain.") - } - - func loadToken() -> String? { - guard let token = KeychainHelper.readString(service: keychainService, account: keychainAccount) else { - return nil - } - self.accessToken = token - return token - } - - func deleteToken() throws { - do { - try KeychainHelper.delete(service: keychainService, account: keychainAccount) - } catch { - logger.error("Keychain delete failed: \(error)") - throw GitHubError.networkError("Failed to delete token from Keychain") - } - self.accessToken = nil - self.currentUser = nil - logger.info("GitHub token deleted from Keychain.") - } - - // MARK: - API Calls - - func fetchUser() async throws -> GitHubUser { - let user: GitHubUser = try await apiRequest(path: "/user") - self.currentUser = user - return user - } - - func fetchRepos() async throws -> [GitHubRepo] { - // Fetch all repos with pagination (including organizations) - var allRepos: [GitHubRepo] = [] - var page = 1 - while true { - let repos: [GitHubRepo] = try await apiRequest( - path: "/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member&page=\(page)" - ) - allRepos.append(contentsOf: repos) - if repos.count < 100 { break } - page += 1 - } - return allRepos - } - - // MARK: - SSH Setup - - /// Generate or reuse an SSH key and return the public key contents. - func setupSSH() async throws -> String { - let exists = await sshKeyManager.keyExists - if !exists { - try await sshKeyManager.generateKey() - } - try await sshKeyManager.configureSSHConfig() - try await sshKeyManager.addToKnownHosts() - return try await sshKeyManager.readPublicKey() - } - - /// Register the given public key with the authenticated GitHub user. - func registerSSHKey(_ publicKey: String) async throws { - let body = try JSONSerialization.data( - withJSONObject: [ - "title": "RxCode (\(Host.current().localizedName ?? "Mac"))", - "key": publicKey - ] - ) - - let _: SSHKeyResponse = try await apiRequest( - path: "/user/keys", - method: "POST", - body: body - ) - - logger.info("SSH key registered with GitHub.") - } - - // MARK: - Clone - - func cloneRepo(_ repo: GitHubRepo, to path: String) async throws { - guard let token = accessToken else { - throw GitHubError.noAccessToken - } - - // HTTPS clone with token — no SSH setup required - let cloneURL = "https://x-access-token:\(token)@github.com/\(repo.fullName).git" - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = ["clone", cloneURL, path] - process.environment = ProcessInfo.processInfo.environment - - let stderrPipe = Pipe() - process.standardError = stderrPipe - - try process.run() - - // Wait for process exit asynchronously instead of blocking the actor - await withCheckedContinuation { (continuation: CheckedContinuation) in - process.terminationHandler = { _ in - continuation.resume() - } - } - - guard process.terminationStatus == 0 else { - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stderr = String(data: stderrData, encoding: .utf8) ?? "unknown error" - throw GitHubError.cloneFailed(stderr) - } - - logger.info("Cloned \(repo.fullName, privacy: .public) to \(path, privacy: .public)") - } - - func cloneRepo(from url: String, to path: String) async throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = ["clone", url, path] - process.environment = ProcessInfo.processInfo.environment - - let stderrPipe = Pipe() - process.standardError = stderrPipe - - try process.run() - - await withCheckedContinuation { (continuation: CheckedContinuation) in - process.terminationHandler = { _ in - continuation.resume() - } - } - - guard process.terminationStatus == 0 else { - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stderr = String(data: stderrData, encoding: .utf8) ?? "unknown error" - throw GitHubError.cloneFailed(stderr) - } - - logger.info("Cloned repo from \(url, privacy: .public) to \(path, privacy: .public)") - } - - // MARK: - Private Helpers - - private func apiRequest( - path: String, - method: String = "GET", - body: Data? = nil - ) async throws -> T { - guard let token = accessToken else { - throw GitHubError.noAccessToken - } - - guard let url = URL(string: "https://api.github.com\(path)") else { - throw GitHubError.networkError("Invalid URL: \(path)") - } - - var request = URLRequest(url: url) - request.httpMethod = method - request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") - - if let body { - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - } - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw GitHubError.invalidResponse - } - - guard (200..<300).contains(httpResponse.statusCode) else { - let responseBody = String(data: data, encoding: .utf8) ?? "no body" - throw GitHubError.apiError(httpResponse.statusCode, responseBody) - } - - do { - let decoder = JSONDecoder() - return try decoder.decode(T.self, from: data) - } catch { - throw GitHubError.decodingError(error.localizedDescription) - } - } -} - -// MARK: - Internal Response Types - -/// Minimal response type for POST /user/keys. -private struct SSHKeyResponse: Decodable { - let id: Int - let key: String - - nonisolated init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - key = try c.decode(String.self, forKey: .key) - } - - enum CodingKeys: CodingKey { case id, key } -} diff --git a/RxCode/Services/PersistenceService.swift b/RxCode/Services/PersistenceService.swift index e6c3419a..12524541 100644 --- a/RxCode/Services/PersistenceService.swift +++ b/RxCode/Services/PersistenceService.swift @@ -20,12 +20,6 @@ protocol AppStatePersistenceService: Actor { func saveACPClients(_ clients: [ACPClientSpec]) throws func loadACPClients() -> [ACPClientSpec] nonisolated func acpRegistrySnapshotURL() -> URL - - func saveCustomRepos(_ repos: [CustomRepo]) throws - func loadCustomRepos() -> [CustomRepo] - - func saveGitHubUser(_ user: GitHubUser) throws - func loadGitHubUser() -> GitHubUser? } extension AppStatePersistenceService { @@ -271,30 +265,6 @@ actor PersistenceService: AppStatePersistenceService { AppSupport.bundleScopedURL.appendingPathComponent("acp_registry.json") } - // MARK: - Custom Git Repositories - - func saveCustomRepos(_ repos: [CustomRepo]) throws { - let url = baseURL.appendingPathComponent("custom_repos.json") - try encode(repos, to: url) - } - - func loadCustomRepos() -> [CustomRepo] { - let url = baseURL.appendingPathComponent("custom_repos.json") - return decode([CustomRepo].self, from: url) ?? [] - } - - // MARK: - GitHub User Cache - - func saveGitHubUser(_ user: GitHubUser) throws { - let url = baseURL.appendingPathComponent("github_user.json") - try encode(user, to: url) - } - - func loadGitHubUser() -> GitHubUser? { - let url = baseURL.appendingPathComponent("github_user.json") - return decode(GitHubUser.self, from: url) - } - // MARK: - Private Helpers private func ensureDirectory(_ url: URL) throws { diff --git a/RxCode/Services/RxAuthService.swift b/RxCode/Services/RxAuthService.swift new file mode 100644 index 00000000..e3a7fcab --- /dev/null +++ b/RxCode/Services/RxAuthService.swift @@ -0,0 +1,87 @@ +import Foundation +import RxAuthSwift +import os + +/// Thin wrapper around `RxAuthSwift.OAuthManager` configured for the rxlab +/// identity provider at `https://auth.rxlab.app`. The same `OAuthManager` is +/// shared by every consumer in the app so its `@Observable` state drives +/// SwiftUI views, while ad-hoc HTTP callers can pull the current bearer via +/// `accessToken()`. +@MainActor +final class RxAuthService { + + static let shared = RxAuthService() + + /// rxauth OAuth client registered for the macOS app. Redirect URI must + /// match `CFBundleURLTypes` in `Info.plist`. + static let clientID = "client_c54bc9da3f244da5be35588e94f20f5e" + static let redirectURI = "rxcode://oauth-callback" + static let issuer = "https://auth.rxlab.app" + + let manager: OAuthManager + private let logger = Logger(subsystem: "com.claudework", category: "RxAuthService") + + init() { + let configuration = RxAuthConfiguration( + issuer: Self.issuer, + clientID: Self.clientID, + redirectURI: Self.redirectURI, + // The rxlab-auth client only allows `openid`; the SDK default + // `["openid","profile","email"]` triggers `invalid_scope`. + scopes: ["openid"], + passkeyChallengePath: "/api/oauth/passkey/authenticate/options", + passkeyVerificationPath: "/api/oauth/passkey/authenticate/verify", + passkeyRegistrationChallengePath: "/api/oauth/passkey/register/options", + passkeyRegistrationVerificationPath: "/api/oauth/passkey/register/verify", + passkeyUpgradeChallengePath: "/api/oauth/passkey/upgrade/options", + passkeyUpgradeVerificationPath: "/api/oauth/passkey/upgrade/verify", + passkeyAccountCreationOptionsPath: "/api/oauth/passkey/account-creation/options", + passkeyAccountCreationVerifyPath: "/api/oauth/passkey/account-creation/verify", + // Must match the `webcredentials:rxlab.app` entitlement and the + // AASA file served at https://rxlab.app/.well-known/apple-app-site-association. + passkeyRelyingPartyIdentifier: "rxlab.app", + keychainServiceName: "com.rxtech.rxcode.rxauth" + ) + self.manager = OAuthManager(configuration: configuration) + } + + var isAuthenticated: Bool { manager.authState == .authenticated } + var user: User? { manager.currentUser } + + /// Returns a current bearer token, refreshing first if it's expired. + /// Returns `nil` when the user is signed out or refresh failed. + func accessToken() async -> String? { + do { + try await manager.refreshTokenIfNeeded() + } catch { + logger.warning("RxAuth refresh failed: \(error.localizedDescription, privacy: .public)") + return nil + } + return KeychainBackedTokenReader.readAccessToken( + service: "com.rxtech.rxcode.rxauth" + ) + } + + func signIn() async throws { + try await manager.authenticate() + } + + func signOut() async { + await manager.logout() + } + + /// Restore a session from the last run if there's one in keychain. + /// Safe to call multiple times — `checkExistingAuth` is idempotent. + func restore() async { + await manager.checkExistingAuth() + } +} + +/// Synchronous access to whatever access token RxAuthSwift currently has in +/// its keychain. RxAuthSwift doesn't expose `tokenStorage` publicly, so we +/// read the same keychain entry it wrote. +enum KeychainBackedTokenReader { + static func readAccessToken(service: String) -> String? { + KeychainHelper.readString(service: service, account: "access_token") + } +} diff --git a/RxCode/Utilities/KeychainHelper.swift b/RxCode/Utilities/KeychainHelper.swift index 9ac31ded..60c02f38 100644 --- a/RxCode/Utilities/KeychainHelper.swift +++ b/RxCode/Utilities/KeychainHelper.swift @@ -3,23 +3,28 @@ import Security enum KeychainHelper { - // MARK: - Read (security CLI — reads items created by other apps without a popup) + // MARK: - Read (SecItem API — same process that wrote the item) nonisolated static func read(service: String, account: String? = nil) -> Data? { - var args = ["find-generic-password", "-s", service, "-w"] + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] if let account { - args.insert(contentsOf: ["-a", account], at: 1) + query[kSecAttrAccount as String] = account } - guard let output = runSecurity(args) else { return nil } - return output.data(using: .utf8) + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { return nil } + return result as? Data } nonisolated static func readString(service: String, account: String? = nil) -> String? { - var args = ["find-generic-password", "-s", service, "-w"] - if let account { - args.insert(contentsOf: ["-a", account], at: 1) - } - return runSecurity(args) + guard let data = read(service: service, account: account) else { return nil } + return String(data: data, encoding: .utf8) } // MARK: - Write / Delete (SecItem API — own app items) @@ -57,27 +62,6 @@ enum KeychainHelper { } } - // MARK: - Private - - private nonisolated static func runSecurity(_ args: [String]) -> String? { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/security") - process.arguments = args - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0 else { return nil } - return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) - } catch { - return nil - } - } - enum KeychainError: Error { case operationFailed(OSStatus) } diff --git a/RxCode/Views/Chat/AutopilotRepoSheet.swift b/RxCode/Views/Chat/AutopilotRepoSheet.swift new file mode 100644 index 00000000..9b94c2ad --- /dev/null +++ b/RxCode/Views/Chat/AutopilotRepoSheet.swift @@ -0,0 +1,545 @@ +import RxAuthSwift +import RxCodeCore +import SwiftUI + +struct AutopilotRepoSheet: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + @Environment(\.dismiss) private var dismiss + + @State private var showSignInSheet = false + @State private var searchText = "" + @State private var cloningRepo: String? + @State private var hoveredRepoId: Int? + /// Debounce task for server-side search. Cancelled and replaced on every + /// keystroke so we only fire one `loadRepos(search:)` after the user pauses. + @State private var searchTask: Task? + + var body: some View { + VStack(spacing: 0) { + titleBar + + ClaudeThemeDivider() + + if appState.isSignedIn { + repoContent + } else { + signInPrompt + } + } + .frame(width: 620, height: 620) + .background(ClaudeTheme.background) + .focusable(false) + .task { + if appState.isSignedIn { + if appState.hasGitHubAppInstalled == nil { + await appState.checkInstallation() + } + if appState.hasGitHubAppInstalled == true, appState.repos.isEmpty { + await appState.loadRepos() + } + } + } + .sheet(isPresented: $showSignInSheet) { + RxAuthSignInView() + .environment(appState) + } + .onChange(of: appState.isSignedIn) { _, signedIn in + // Dismiss the sign-in prompt as soon as the shared + // `OAuthManager` flips to authenticated — covers the case where + // sign-in completes from a different surface (e.g. settings). + if signedIn { + showSignInSheet = false + } + } + .onChange(of: searchText) { _, newValue in + // Debounce keystrokes — fire one server query after the user + // pauses typing. Empty string reloads the unfiltered first page. + searchTask?.cancel() + searchTask = Task { [newValue] in + try? await Task.sleep(nanoseconds: 250_000_000) + if Task.isCancelled { return } + await appState.loadRepos(search: newValue) + } + } + .onDisappear { + searchTask?.cancel() + searchTask = nil + } + } + + private var titleBar: some View { + HStack { + Text("Import Repository") + .font(.headline) + .foregroundStyle(ClaudeTheme.textPrimary) + + Spacer() + + if appState.isSignedIn, let user = appState.rxUser { + Text(user.name ?? user.email ?? user.id) + .font(.caption) + .foregroundStyle(ClaudeTheme.textSecondary) + .lineLimit(1) + + // Always-available install entry point — users with one + // installation often need to add another (e.g. an org they + // don't yet own a repo from). + Button { + Task { await appState.openInstallGitHubApp() } + } label: { + if appState.isOpeningInstallUrl { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: "app.badge.checkmark") + .font(.caption) + .foregroundStyle(ClaudeTheme.textSecondary) + } + } + .buttonStyle(.borderless) + .focusable(false) + .disabled(appState.isOpeningInstallUrl) + .help("Install GitHub App on another account") + + Button { + Task { await appState.refreshInstallationAndRepos() } + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption) + .foregroundStyle(ClaudeTheme.textSecondary) + } + .buttonStyle(.borderless) + .focusable(false) + .disabled(appState.isCheckingInstall || appState.isLoadingRepos) + .help("Refresh") + } + + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private var signInPrompt: some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "link.badge.plus") + .font(.system(size: ClaudeTheme.size(40))) + .foregroundStyle(ClaudeTheme.accent) + + Text("Sign in to rxlab to\nimport your GitHub repos") + .font(.body) + .foregroundStyle(ClaudeTheme.textSecondary) + .multilineTextAlignment(.center) + + Button { + showSignInSheet = true + } label: { + Label("Sign in with rxlab", systemImage: "person.crop.circle.badge.plus") + } + .buttonStyle(ClaudeAccentButtonStyle()) + + Spacer() + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var repoContent: some View { + // The precheck runs in `.task`; until it lands, show the same loading + // spinner so we don't flash an install CTA at users who already have + // installations. + if appState.hasGitHubAppInstalled == nil || appState.isCheckingInstall { + loadingState + } else if appState.hasGitHubAppInstalled == false { + installGitHubAppState + } else { + VStack(spacing: 0) { + searchBar + + ClaudeThemeDivider() + + if appState.isLoadingRepos { + loadingState + } else if appState.repos.isEmpty { + if searchText.trimmingCharacters(in: .whitespaces).isEmpty { + emptyState + } else { + noMatchesState + } + } else { + repoListContent + repoListFooter + } + } + } + } + + private var searchBar: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + TextField("Search repos...", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .foregroundStyle(ClaudeTheme.textPrimary) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 13)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .buttonStyle(.borderless) + .focusable(false) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(ClaudeTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private var loadingState: some View { + VStack(spacing: 10) { + Spacer() + ProgressView() + .controlSize(.regular) + Text("Loading repos...") + .font(.subheadline) + .foregroundStyle(ClaudeTheme.textSecondary) + Spacer() + } + .frame(maxWidth: .infinity) + } + + private var emptyState: some View { + VStack(spacing: 8) { + Spacer() + Image(systemName: "tray") + .font(.system(size: 28, weight: .light)) + .foregroundStyle(ClaudeTheme.textTertiary) + Text("No repos found") + .font(.subheadline) + .foregroundStyle(ClaudeTheme.textSecondary) + Spacer() + } + .frame(maxWidth: .infinity) + } + + private var noMatchesState: some View { + VStack(spacing: 8) { + Spacer() + Image(systemName: "magnifyingglass") + .font(.system(size: 24, weight: .light)) + .foregroundStyle(ClaudeTheme.textTertiary) + Text("No repos match \u{201C}\(searchText)\u{201D}") + .font(.subheadline) + .foregroundStyle(ClaudeTheme.textSecondary) + Spacer() + } + .frame(maxWidth: .infinity) + } + + /// Shown when the precheck reports zero GitHub App installations for the + /// signed-in user. Primary action opens the GitHub install URL in the + /// default browser; secondary Refresh re-runs the precheck so the sheet + /// can flip to the repo list once the install completes. + private var installGitHubAppState: some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "app.badge.checkmark") + .font(.system(size: ClaudeTheme.size(40))) + .foregroundStyle(ClaudeTheme.accent) + + VStack(spacing: 6) { + Text("Install the GitHub App") + .font(.headline) + .foregroundStyle(ClaudeTheme.textPrimary) + + Text("RxCode needs the GitHub App to see your repositories. Install it for the account or organizations you want to import from.") + .font(.subheadline) + .foregroundStyle(ClaudeTheme.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + + HStack(spacing: 8) { + Button { + Task { await appState.openInstallGitHubApp() } + } label: { + if appState.isOpeningInstallUrl { + ProgressView() + .controlSize(.small) + } else { + Label("Install GitHub App", systemImage: "arrow.up.right.square") + } + } + .buttonStyle(ClaudeAccentButtonStyle()) + .disabled(appState.isOpeningInstallUrl) + + Button { + Task { await appState.refreshInstallationAndRepos() } + } label: { + if appState.isCheckingInstall { + ProgressView() + .controlSize(.small) + } else { + Label("Refresh", systemImage: "arrow.clockwise") + } + } + .buttonStyle(ClaudeSecondaryButtonStyle()) + .disabled(appState.isCheckingInstall) + } + + Text("Finished installing? Tap Refresh.") + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + + Spacer() + } + .frame(maxWidth: .infinity) + } + + private var repoListContent: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(appState.repos.enumerated()), id: \.element.id) { index, repo in + if index > 0 { + ClaudeThemeDivider() + .padding(.leading, 16) + } + repoRow(repo) + .onAppear { + // Infinite scroll: when the row a couple before the + // end becomes visible, page the next batch from the + // server. `loadMoreRepos()` is itself a no-op when + // a load is in flight or `hasMore` is false, so + // firing eagerly is safe. + if index >= appState.repos.count - 5 { + Task { await appState.loadMoreRepos() } + } + } + } + + if appState.isLoadingMoreRepos { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Loading more...") + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + } + } + .scrollContentBackground(.hidden) + .background(ClaudeTheme.background) + } + + private var repoListFooter: some View { + HStack { + Text(repoFooterLabel) + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(ClaudeTheme.surfaceSecondary.opacity(0.4)) + .overlay(alignment: .top) { ClaudeThemeDivider() } + } + + /// "12 repos" while there are more pages to load, "12 of 12 repos" once + /// we've reached the end — gives the user a visible "done" signal so + /// they don't keep scrolling waiting for more. + private var repoFooterLabel: String { + let count = appState.repos.count + let noun = count == 1 ? "repo" : "repos" + if appState.repoHasMoreRepos { + return "\(count) \(noun)" + } + return "\(count) of \(count) \(noun)" + } + + private func repoRow(_ repo: AutopilotRepo) -> some View { + let added = isAlreadyAdded(repo) + let cloning = cloningRepo == repo.fullName + let hovered = hoveredRepoId == repo.id + + return HStack(alignment: .top, spacing: 12) { + // Owner avatar / fallback icon — keeps each row visually anchored + // even when names are very similar across orgs. + ownerAvatar(for: repo) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(repo.name) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + + visibilityBadge(for: repo) + + Text(repo.owner) + .font(.system(size: 11)) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + } + + if let description = repo.description, !description.isEmpty { + Text(description) + .font(.system(size: 12)) + .foregroundStyle(ClaudeTheme.textSecondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + + if let updated = relativeUpdated(repo.updatedAt) { + HStack(spacing: 4) { + Image(systemName: "clock") + .font(.system(size: 9)) + Text("Updated \(updated)") + .font(.system(size: 11)) + } + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + + Spacer(minLength: 8) + + rowTrailing(repo: repo, added: added, cloning: cloning, hovered: hovered) + .frame(minWidth: 76, alignment: .trailing) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + hovered && !added + ? ClaudeTheme.surfaceSecondary.opacity(0.5) + : Color.clear + ) + .contentShape(Rectangle()) + .onHover { hovering in + hoveredRepoId = hovering ? repo.id : (hoveredRepoId == repo.id ? nil : hoveredRepoId) + } + .onTapGesture { + guard !added, !cloning else { return } + Task { await cloneRepo(repo) } + } + } + + @ViewBuilder + private func ownerAvatar(for repo: AutopilotRepo) -> some View { + let installation = appState.installations.first { $0.accountLogin.caseInsensitiveCompare(repo.owner) == .orderedSame } + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(ClaudeTheme.surfaceSecondary) + .frame(width: 28, height: 28) + + if let urlString = installation?.accountAvatarUrl, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().aspectRatio(contentMode: .fill) + default: + Image(systemName: "person.crop.square.fill") + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + .frame(width: 28, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } else { + Text(String(repo.owner.prefix(1)).uppercased()) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textSecondary) + } + } + } + + private func visibilityBadge(for repo: AutopilotRepo) -> some View { + HStack(spacing: 3) { + Image(systemName: repo.isPrivate ? "lock.fill" : "globe") + .font(.system(size: 9, weight: .semibold)) + Text(repo.isPrivate ? "Private" : "Public") + .font(.system(size: 10, weight: .medium)) + } + .foregroundStyle(ClaudeTheme.textSecondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule().fill(ClaudeTheme.surfaceSecondary) + ) + } + + @ViewBuilder + private func rowTrailing(repo: AutopilotRepo, added: Bool, cloning: Bool, hovered: Bool) -> some View { + if cloning { + ProgressView() + .controlSize(.small) + } else if added { + Label("Added", systemImage: "checkmark.circle.fill") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ClaudeTheme.statusSuccess) + .labelStyle(.titleAndIcon) + } else { + Button { + Task { await cloneRepo(repo) } + } label: { + Label("Add", systemImage: "plus") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(ClaudeSecondaryButtonStyle()) + .opacity(hovered ? 1.0 : 0.75) + } + } + + private func relativeUpdated(_ raw: String?) -> String? { + guard let raw, !raw.isEmpty else { return nil } + let isoFractional = ISO8601DateFormatter() + isoFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let isoPlain = ISO8601DateFormatter() + isoPlain.formatOptions = [.withInternetDateTime] + let date = isoFractional.date(from: raw) ?? isoPlain.date(from: raw) + guard let date else { return nil } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: Date()) + } + + private func isAlreadyAdded(_ repo: AutopilotRepo) -> Bool { + appState.projects.contains { $0.gitHubRepo == repo.fullName } + } + + private func cloneRepo(_ repo: AutopilotRepo) async { + cloningRepo = repo.fullName + do { + try await appState.cloneAndAddProject(repo, in: windowState) + } catch { + windowState.errorMessage = "Clone failed: \(error.localizedDescription)" + windowState.showError = true + } + cloningRepo = nil + } +} + +#Preview { + AutopilotRepoSheet() + .environment(AppState()) + .environment(WindowState()) +} diff --git a/RxCode/Views/Chat/GitHubSheet.swift b/RxCode/Views/Chat/GitHubSheet.swift deleted file mode 100644 index 6eadba61..00000000 --- a/RxCode/Views/Chat/GitHubSheet.swift +++ /dev/null @@ -1,459 +0,0 @@ -import SwiftUI -import RxCodeCore - -struct GitHubSheet: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - @Environment(\.dismiss) private var dismiss - @State private var showLoginSheet = false - @State private var searchText = "" - @State private var cloningRepo: String? - @State private var selectedTab = 0 - @State private var customRepoURL = "" - @State private var customRepoName = "" - @State private var isAddingCustomRepo = false - @State private var cloningCustomRepo: String? - - var body: some View { - VStack(spacing: 0) { - // Title bar - HStack { - Text("Git Repositories") - .font(.headline) - .foregroundStyle(ClaudeTheme.textPrimary) - - Spacer() - - if selectedTab == 0, appState.isLoggedIn, let user = appState.gitHubUser { - Text("@\(user.login)") - .font(.caption) - .foregroundStyle(ClaudeTheme.textSecondary) - - Button { - Task { await appState.fetchRepos() } - } label: { - Image(systemName: "arrow.clockwise") - .font(.caption) - .foregroundStyle(ClaudeTheme.textSecondary) - } - .buttonStyle(.borderless) - .focusable(false) - .help("Refresh") - } - - Button { - dismiss() - } label: { - Image(systemName: "xmark.circle.fill") - .font(.title3) - .foregroundStyle(ClaudeTheme.textTertiary) - } - .buttonStyle(.borderless) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - // Tab picker - Picker("Source", selection: $selectedTab) { - Text("GitHub").tag(0) - Text("Custom").tag(1) - } - .pickerStyle(.segmented) - .padding(.horizontal, 16) - .padding(.bottom, 8) - - ClaudeThemeDivider() - - // Content - if selectedTab == 0 { - githubContent - } else { - customContent - } - } - .frame(width: 480, height: 520) - .background(ClaudeTheme.background) - .focusable(false) - .task { - if appState.isLoggedIn, appState.repos.isEmpty { - await appState.fetchRepos() - } - } - .sheet(isPresented: $showLoginSheet) { - GitHubLoginView() - } - } - - // MARK: - GitHub Content - - private var githubContent: some View { - Group { - if appState.isLoggedIn { - repoContent - } else { - connectPrompt - } - } - } - - // MARK: - Connect Prompt - - private var connectPrompt: some View { - VStack(spacing: 16) { - Spacer() - - Image(systemName: "link.badge.plus") - .font(.system(size: ClaudeTheme.size(40))) - .foregroundStyle(ClaudeTheme.accent) - - Text("Connect GitHub to\nimport repos instantly") - .font(.body) - .foregroundStyle(ClaudeTheme.textSecondary) - .multilineTextAlignment(.center) - - Button { - showLoginSheet = true - } label: { - Label("Connect GitHub", systemImage: "person.crop.circle.badge.plus") - } - .buttonStyle(ClaudeAccentButtonStyle()) - - Spacer() - } - .frame(maxWidth: .infinity) - } - - // MARK: - Repo Content - - private var repoContent: some View { - VStack(spacing: 0) { - // Search bar - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(ClaudeTheme.textTertiary) - TextField("Search repos...", text: $searchText) - .textFieldStyle(.plain) - .foregroundStyle(ClaudeTheme.textPrimary) - } - .padding(8) - .background(ClaudeTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) - .padding(.horizontal, 16) - .padding(.vertical, 8) - - ClaudeThemeDivider() - - if appState.isFetchingRepos { - loadingState - } else if appState.repos.isEmpty { - emptyState - } else { - repoListContent - } - - ClaudeThemeDivider() - - // Footer - HStack { - Link("Don't see your org repos? →", - destination: URL(string: "https://github.com/settings/connections/applications/\(GitHubService.oauthClientId)")!) - .font(.caption) - .foregroundStyle(ClaudeTheme.textSecondary) - - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - } - - // MARK: - Custom Content - - private var customContent: some View { - VStack(spacing: 0) { - // Add button - HStack { - Button { - withAnimation { - isAddingCustomRepo = true - } - } label: { - Label("Add Repository", systemImage: "plus") - } - .buttonStyle(ClaudeAccentButtonStyle()) - - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - - ClaudeThemeDivider() - - if isAddingCustomRepo { - addCustomRepoForm - } - - if appState.customRepos.isEmpty, !isAddingCustomRepo { - customEmptyState - } else { - customRepoList - } - } - } - - private var addCustomRepoForm: some View { - VStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Git URL") - .font(.caption) - .foregroundStyle(ClaudeTheme.textSecondary) - TextField("https://github.com/owner/repo.git or git@github.com:owner/repo.git", text: $customRepoURL) - .textFieldStyle(.roundedBorder) - } - - VStack(alignment: .leading, spacing: 4) { - Text("Project Name") - .font(.caption) - .foregroundStyle(ClaudeTheme.textSecondary) - TextField("my-project", text: $customRepoName) - .textFieldStyle(.roundedBorder) - } - - HStack { - Button("Cancel") { - withAnimation { - isAddingCustomRepo = false - customRepoURL = "" - customRepoName = "" - } - } - .buttonStyle(ClaudeSecondaryButtonStyle()) - - Spacer() - - Button { - Task { await cloneCustomRepo() } - } label: { - if cloningCustomRepo != nil { - ProgressView() - .controlSize(.small) - } else { - Text("Clone & Add") - } - } - .buttonStyle(ClaudeAccentButtonStyle()) - .disabled(customRepoURL.isEmpty || customRepoName.isEmpty || cloningCustomRepo != nil) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - - private var customEmptyState: some View { - VStack(spacing: 8) { - Spacer() - Image(systemName: "archivebox") - .font(.system(size: 32)) - .foregroundStyle(ClaudeTheme.textTertiary) - Text("No custom repositories") - .font(.subheadline) - .foregroundStyle(ClaudeTheme.textSecondary) - Text("Add a Git repository by URL") - .font(.caption) - .foregroundStyle(ClaudeTheme.textTertiary) - Spacer() - } - .frame(maxWidth: .infinity) - } - - private var customRepoList: some View { - List { - ForEach(appState.customRepos) { repo in - HStack(spacing: 10) { - Image(systemName: "archivebox.fill") - .font(.caption) - .foregroundStyle(ClaudeTheme.textTertiary) - .frame(width: 16) - - VStack(alignment: .leading, spacing: 2) { - Text(repo.name) - .font(.body) - .foregroundStyle(ClaudeTheme.textPrimary) - .lineLimit(1) - Text(repo.cloneURL) - .font(.caption) - .foregroundStyle(ClaudeTheme.textTertiary) - .lineLimit(1) - } - - Spacer() - - if cloningCustomRepo == repo.name { - ProgressView() - .controlSize(.small) - } else if isCustomRepoAdded(repo) { - Label("Added", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(ClaudeTheme.statusSuccess) - } else { - Button { - Task { await cloneCustomRepo(repo) } - } label: { - Label("Clone", systemImage: "plus.circle") - .font(.caption) - } - .buttonStyle(ClaudeSecondaryButtonStyle()) - } - - Button(role: .destructive) { - Task { await appState.removeCustomRepo(repo) } - } label: { - Image(systemName: "trash") - .font(.caption) - .foregroundStyle(ClaudeTheme.textTertiary) - } - .buttonStyle(.borderless) - } - .padding(.vertical, 2) - } - } - .listStyle(.plain) - } - - private var loadingState: some View { - VStack(spacing: 10) { - Spacer() - ProgressView() - .controlSize(.regular) - Text("Loading repos...") - .font(.subheadline) - .foregroundStyle(ClaudeTheme.textSecondary) - Spacer() - } - .frame(maxWidth: .infinity) - } - - private var emptyState: some View { - VStack(spacing: 8) { - Spacer() - Text("No repos found") - .font(.subheadline) - .foregroundStyle(ClaudeTheme.textSecondary) - Spacer() - } - .frame(maxWidth: .infinity) - } - - private var repoListContent: some View { - List(filteredRepos) { repo in - repoRow(repo) - } - .listStyle(.plain) - } - - private func repoRow(_ repo: GitHubRepo) -> some View { - HStack(spacing: 10) { - Image(systemName: repo.isPrivate ? "lock.fill" : "globe") - .font(.caption) - .foregroundStyle(ClaudeTheme.textTertiary) - .frame(width: 16) - - VStack(alignment: .leading, spacing: 2) { - Text(repo.name) - .font(.body) - .foregroundStyle(ClaudeTheme.textPrimary) - .lineLimit(1) - Text(repo.fullName) - .font(.caption) - .foregroundStyle(ClaudeTheme.textTertiary) - .lineLimit(1) - } - - Spacer() - - if cloningRepo == repo.fullName { - ProgressView() - .controlSize(.small) - } else if isAlreadyAdded(repo) { - Label("Added", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(ClaudeTheme.statusSuccess) - } else { - Button { - Task { await cloneRepo(repo) } - } label: { - Label("Add", systemImage: "plus.circle") - .font(.caption) - } - .buttonStyle(ClaudeSecondaryButtonStyle()) - } - } - .padding(.vertical, 2) - } - - // MARK: - Helpers - - private var filteredRepos: [GitHubRepo] { - if searchText.isEmpty { - return appState.repos - } - return appState.repos.filter { - $0.name.localizedCaseInsensitiveContains(searchText) || - $0.fullName.localizedCaseInsensitiveContains(searchText) - } - } - - private func isAlreadyAdded(_ repo: GitHubRepo) -> Bool { - appState.projects.contains { $0.gitHubRepo == repo.fullName } - } - - private func isCustomRepoAdded(_ repo: CustomRepo) -> Bool { - appState.projects.contains { $0.path.contains(repo.name) } - } - - private func cloneRepo(_ repo: GitHubRepo) async { - cloningRepo = repo.fullName - do { - try await appState.cloneAndAddProject(repo, in: windowState) - } catch { - windowState.errorMessage = "Clone failed: \(error.localizedDescription)" - windowState.showError = true - } - cloningRepo = nil - } - - private func cloneCustomRepo(_ repo: CustomRepo? = nil) async { - if let repo { - cloningCustomRepo = repo.name - do { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let clonePath = "\(home)/RxCode/\(repo.name)" - let fm = FileManager.default - if !fm.fileExists(atPath: "\(home)/RxCode") { - try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) - } - try await appState.cloneCustomRepo(repo, in: windowState) - } catch { - windowState.errorMessage = "Clone failed: \(error.localizedDescription)" - windowState.showError = true - } - cloningCustomRepo = nil - } else { - cloningCustomRepo = customRepoName - do { - try await appState.addCustomRepo(url: customRepoURL, name: customRepoName, in: windowState) - customRepoURL = "" - customRepoName = "" - isAddingCustomRepo = false - } catch { - windowState.errorMessage = "Clone failed: \(error.localizedDescription)" - windowState.showError = true - } - cloningCustomRepo = nil - } - } -} - -#Preview { - GitHubSheet() - .environment(AppState()) -} diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index f7873bbc..0d286d6e 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -183,13 +183,9 @@ struct MainView: View { Button { showGitHubSheet = true } label: { - Image("GitHubMark") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) + Image(systemName: "square.and.arrow.down") } - .help(appState.isLoggedIn ? "Manage GitHub Repos" : "Connect GitHub") + .help(appState.isSignedIn ? "Import Repository" : "Sign in to rxlab") } ToolbarItem(placement: .navigation) { @@ -233,7 +229,7 @@ struct MainView: View { .background(ClaudeTheme.sidebarBackground.ignoresSafeArea()) .navigationSplitViewColumnWidth(min: 250, ideal: 250, max: 500) .sheet(isPresented: $showGitHubSheet) { - GitHubSheet() + AutopilotRepoSheet() } } diff --git a/RxCode/Views/Onboarding/GitHubLoginView.swift b/RxCode/Views/Onboarding/GitHubLoginView.swift deleted file mode 100644 index cb38059c..00000000 --- a/RxCode/Views/Onboarding/GitHubLoginView.swift +++ /dev/null @@ -1,174 +0,0 @@ -import SwiftUI -import RxCodeCore - -struct GitHubLoginView: View { - @Environment(AppState.self) private var appState - @State private var userCode: String? - @State private var verificationURL: String? - @State private var isPolling = false - @State private var isStarting = false - @State private var errorMessage: String? - @State private var codeCopied = false - - @Environment(\.dismiss) private var dismiss - - var body: some View { - VStack(spacing: 20) { - Image(systemName: "person.crop.circle.badge.checkmark") - .font(.system(size: ClaudeTheme.size(48))) - .foregroundStyle(ClaudeTheme.accent) - - Text("Connect GitHub") - .font(.title2) - .fontWeight(.semibold) - .foregroundStyle(ClaudeTheme.textPrimary) - - if appState.isLoggedIn { - authenticatedView - } else if let code = userCode { - deviceCodeView(code: code) - } else { - startView - } - - if let error = errorMessage { - Text(error) - .font(.caption) - .foregroundStyle(ClaudeTheme.statusError) - .multilineTextAlignment(.center) - } - } - .padding(24) - .frame(width: 480, height: 400) - .background(ClaudeTheme.background) - } - - // MARK: - Start View - - private var startView: some View { - VStack(spacing: 12) { - Text("Connect GitHub to fetch your repo list and add projects with a single click.") - .font(.subheadline) - .foregroundStyle(ClaudeTheme.textSecondary) - .multilineTextAlignment(.center) - - Button { - Task { await startDeviceFlow() } - } label: { - Label("Sign in with GitHub", systemImage: "arrow.right.circle") - } - .buttonStyle(ClaudeAccentButtonStyle()) - .disabled(isStarting) - - if isStarting { - ProgressView() - .controlSize(.small) - } - } - } - - // MARK: - Device Code View - - private func deviceCodeView(code: String) -> some View { - VStack(spacing: 16) { - VStack(spacing: 6) { - Text("Authentication Code") - .font(.subheadline) - .foregroundStyle(ClaudeTheme.textSecondary) - - Text(code) - .font(.system(.title, design: .monospaced)) - .fontWeight(.bold) - .foregroundStyle(ClaudeTheme.accent) - .textSelection(.enabled) - } - - HStack(spacing: 12) { - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(code, forType: .string) - codeCopied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - codeCopied = false - } - } label: { - Label(codeCopied ? "Copied" : "Copy Code", - systemImage: codeCopied ? "checkmark" : "doc.on.doc") - } - .buttonStyle(ClaudeSecondaryButtonStyle()) - - Button { - if let url = verificationURL.flatMap({ URL(string: $0) }) { - NSWorkspace.shared.open(url) - } - } label: { - Label("Authenticate on GitHub", systemImage: "safari") - } - .buttonStyle(ClaudeAccentButtonStyle()) - } - - if isPolling { - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - Text("Waiting for authentication...") - .font(.caption) - .foregroundStyle(ClaudeTheme.textSecondary) - } - } - } - } - - // MARK: - Authenticated View - - private var authenticatedView: some View { - VStack(spacing: 12) { - Label("Connected", systemImage: "checkmark.circle.fill") - .foregroundStyle(ClaudeTheme.statusSuccess) - .font(.title3) - - if let user = appState.gitHubUser { - Text("@\(user.login)") - .font(.subheadline) - .foregroundStyle(ClaudeTheme.textSecondary) - } - - Button("Close") { - dismiss() - } - .buttonStyle(ClaudeSecondaryButtonStyle()) - } - } - - // MARK: - Logic - - private func startDeviceFlow() async { - isStarting = true - errorMessage = nil - - do { - let response = try await appState.loginToGitHub() - userCode = response.userCode - verificationURL = response.verificationUri - isStarting = false - - isPolling = true - try await appState.completeGitHubLogin( - deviceCode: response.deviceCode, - interval: response.interval - ) - isPolling = false - - await appState.fetchRepos() - } catch { - isStarting = false - isPolling = false - errorMessage = error.localizedDescription - } - } -} - -#Preview { - GitHubLoginView() - .environment(AppState()) -} diff --git a/RxCode/Views/Onboarding/OnboardingView.swift b/RxCode/Views/Onboarding/OnboardingView.swift index 9c9e31f6..df80fe55 100644 --- a/RxCode/Views/Onboarding/OnboardingView.swift +++ b/RxCode/Views/Onboarding/OnboardingView.swift @@ -132,7 +132,8 @@ struct OnboardingView: View { HStack { Spacer() Button("Get Started") { - appState.skipGitHubLogin() + appState.onboardingCompleted = true + UserDefaults.standard.set(true, forKey: "onboardingCompleted") onCompletion?() } .buttonStyle(ClaudeAccentButtonStyle()) diff --git a/RxCode/Views/Onboarding/RxAuthSignInView.swift b/RxCode/Views/Onboarding/RxAuthSignInView.swift new file mode 100644 index 00000000..8c4c8541 --- /dev/null +++ b/RxCode/Views/Onboarding/RxAuthSignInView.swift @@ -0,0 +1,63 @@ +import RxAuthSwift +import RxAuthSwiftUI +import RxCodeCore +import SwiftUI +import os + +struct RxAuthSignInView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + private static let logger = Logger(subsystem: "com.claudework", category: "RxAuthSignInView") + + var body: some View { + let manager = appState.rxAuth.manager + RxSignInView( + manager: manager, + appearance: RxSignInAppearance( + title: "Welcome to RxCode", + subtitle: "Sign in with rxlab to import GitHub repositories.", + accentColor: ClaudeTheme.accent + ), + onAuthSuccess: { [appState] in + Self.logger.info("rxauth sign-in succeeded; state=\(String(describing: manager.authState), privacy: .public)") + Task { @MainActor in + appState.onRxAuthSignedIn() + dismiss() + } + }, + onAuthFailed: { error in + Self.logger.error("rxauth sign-in failed: \(String(describing: error), privacy: .public) — manager.errorMessage=\(manager.errorMessage ?? "", privacy: .public)") + } + ) + .onAppear { + Self.logger.info("RxAuthSignInView appeared — issuer=\(RxAuthService.issuer, privacy: .public) clientID=\(RxAuthService.clientID, privacy: .public) redirectURI=\(RxAuthService.redirectURI, privacy: .public) supportsPasskey=\(manager.supportsPasskeyAuthentication) state=\(String(describing: manager.authState), privacy: .public)") + } + .onChange(of: manager.isAuthenticating) { _, newValue in + Self.logger.info("isAuthenticating -> \(newValue) errorMessage=\(manager.errorMessage ?? "", privacy: .public)") + } + .onChange(of: manager.errorMessage) { _, newValue in + Self.logger.info("manager.errorMessage -> \(newValue ?? "", privacy: .public)") + } + .overlay(alignment: .topTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + .padding(12) + .help("Close") + } + .frame(minWidth: 480, idealWidth: 540, minHeight: 520, idealHeight: 620) + } +} + +#Preview { + RxAuthSignInView() + .environment(AppState()) +} diff --git a/RxCode/Views/Settings/AutopilotSettingsTab.swift b/RxCode/Views/Settings/AutopilotSettingsTab.swift new file mode 100644 index 00000000..ba512654 --- /dev/null +++ b/RxCode/Views/Settings/AutopilotSettingsTab.swift @@ -0,0 +1,144 @@ +import RxAuthSwift +import RxCodeCore +import SwiftUI + +/// "Autopilot" tab in SettingsView. Shows the signed-in rxlab user and lets +/// the user sign in or sign out of the autopilot integration. +struct AutopilotSettingsTab: View { + @Environment(AppState.self) private var appState + @State private var showSignIn = false + @State private var isSigningOut = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + headerSection + Divider() + accountSection + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .sheet(isPresented: $showSignIn) { + RxAuthSignInView() + .environment(appState) + } + } + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Autopilot") + .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) + Text("Sign in with rxlab to import GitHub repositories and use autopilot features.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + private var accountSection: some View { + if appState.isSignedIn { + signedInCard + } else { + signedOutCard + } + } + + private var signedInCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + avatar + VStack(alignment: .leading, spacing: 2) { + Text(appState.rxUser?.name ?? "rxlab account") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + if let email = appState.rxUser?.email { + Text(verbatim: email) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + Button { + Task { + isSigningOut = true + defer { isSigningOut = false } + await appState.signOutRxAuth() + } + } label: { + if isSigningOut { + ProgressView().controlSize(.small) + } else { + Text("Sign Out") + } + } + .disabled(isSigningOut) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + } + + private var signedOutCard: some View { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle.badge.questionmark") + .font(.system(size: ClaudeTheme.size(20))) + .foregroundStyle(.secondary) + .frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 2) { + Text("Not signed in") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("Connect your rxlab account to enable autopilot.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button("Sign In") { showSignIn = true } + .buttonStyle(.borderedProminent) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + + @ViewBuilder + private var avatar: some View { + if let urlString = appState.rxUser?.image, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + default: + avatarPlaceholder + } + } + .frame(width: 36, height: 36) + .clipShape(Circle()) + } else { + avatarPlaceholder + } + } + + private var avatarPlaceholder: some View { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: ClaudeTheme.size(30))) + .foregroundStyle(.secondary) + .frame(width: 36, height: 36) + } +} + +#Preview { + AutopilotSettingsTab() + .environment(AppState()) +} diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 814fabdd..01d84454 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -58,6 +58,12 @@ struct SettingsView: View { Label("Mobile", systemImage: "iphone.gen3") } .tag(6) + + AutopilotSettingsTab() + .tabItem { + Label("Autopilot", systemImage: "paperplane.circle") + } + .tag(7) } .frame(width: 680, height: 620) .focusable(false) diff --git a/RxCode/Views/Sidebar/GitHubRepoListView.swift b/RxCode/Views/Sidebar/AutopilotRepoListView.swift similarity index 69% rename from RxCode/Views/Sidebar/GitHubRepoListView.swift rename to RxCode/Views/Sidebar/AutopilotRepoListView.swift index c9535cbd..1363d3f7 100644 --- a/RxCode/Views/Sidebar/GitHubRepoListView.swift +++ b/RxCode/Views/Sidebar/AutopilotRepoListView.swift @@ -1,10 +1,11 @@ -import SwiftUI +import RxAuthSwift import RxCodeCore +import SwiftUI -struct GitHubRepoListView: View { +struct AutopilotRepoListView: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState - @State private var showLoginSheet = false + @State private var showSignInSheet = false @State private var searchText = "" @State private var cloningRepo: String? @@ -14,16 +15,14 @@ struct GitHubRepoListView: View { .padding(.horizontal, 12) .padding(.vertical, 8) - if appState.isLoggedIn { + if appState.isSignedIn { repoList } else { - connectPrompt + signInPrompt } } } - // MARK: - Header - private var headerRow: some View { HStack { Text("GitHub") @@ -31,15 +30,16 @@ struct GitHubRepoListView: View { Spacer() - if appState.isLoggedIn { - if let user = appState.gitHubUser { - Text("@\(user.login)") + if appState.isSignedIn { + if let user = appState.rxUser { + Text(user.name ?? user.email ?? user.id) .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) } Button { - Task { await appState.fetchRepos() } + Task { await appState.loadRepos(refresh: true) } } label: { Image(systemName: "arrow.clockwise") } @@ -49,9 +49,7 @@ struct GitHubRepoListView: View { } } - // MARK: - Connect Prompt - - private var connectPrompt: some View { + private var signInPrompt: some View { VStack(spacing: 12) { Spacer() @@ -59,15 +57,15 @@ struct GitHubRepoListView: View { .font(.title2) .foregroundStyle(.secondary) - Text("Connect GitHub to\nimport repos instantly") + Text("Sign in to rxlab to\nimport GitHub repos") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button { - showLoginSheet = true + showSignInSheet = true } label: { - Label("Connect GitHub", systemImage: "person.crop.circle.badge.plus") + Label("Sign in with rxlab", systemImage: "person.crop.circle.badge.plus") } .buttonStyle(.borderedProminent) .controlSize(.regular) @@ -75,33 +73,32 @@ struct GitHubRepoListView: View { Spacer() } .frame(maxWidth: .infinity) - .sheet(isPresented: $showLoginSheet) { - GitHubLoginView() + .sheet(isPresented: $showSignInSheet) { + RxAuthSignInView() } } - // MARK: - Repo List - private var repoList: some View { VStack(spacing: 0) { - // Search TextField("Search repos...", text: $searchText) .textFieldStyle(.roundedBorder) .padding(.horizontal, 12) .padding(.bottom, 8) - if appState.repos.isEmpty { + if appState.isLoadingRepos { + VStack(spacing: 8) { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + .frame(maxWidth: .infinity) + } else if appState.repos.isEmpty { VStack(spacing: 8) { Spacer() Text("No repos found") .font(.subheadline) .foregroundStyle(.secondary) - Text("Don't see your org repos?") - .font(.caption) - .foregroundStyle(.secondary) - Link("Configure org access →", - destination: URL(string: "https://github.com/settings/connections/applications/\(GitHubService.oauthClientId)")!) - .font(.caption) Spacer() } .frame(maxWidth: .infinity) @@ -110,20 +107,11 @@ struct GitHubRepoListView: View { repoRow(repo) } .listStyle(.sidebar) - - if !appState.repos.isEmpty { - Link("Don't see your org repos? →", - destination: URL(string: "https://github.com/settings/connections/applications/\(GitHubService.oauthClientId)")!) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 12) - .padding(.vertical, 6) - } } } } - private func repoRow(_ repo: GitHubRepo) -> some View { + private func repoRow(_ repo: AutopilotRepo) -> some View { HStack(spacing: 8) { Image(systemName: repo.isPrivate ? "lock.fill" : "globe") .font(.caption) @@ -163,9 +151,7 @@ struct GitHubRepoListView: View { .padding(.vertical, 2) } - // MARK: - Helpers - - private var filteredRepos: [GitHubRepo] { + private var filteredRepos: [AutopilotRepo] { if searchText.isEmpty { return appState.repos } @@ -175,11 +161,11 @@ struct GitHubRepoListView: View { } } - private func isAlreadyAdded(_ repo: GitHubRepo) -> Bool { + private func isAlreadyAdded(_ repo: AutopilotRepo) -> Bool { appState.projects.contains { $0.gitHubRepo == repo.fullName } } - private func cloneRepo(_ repo: GitHubRepo) async { + private func cloneRepo(_ repo: AutopilotRepo) async { cloningRepo = repo.fullName do { try await appState.cloneAndAddProject(repo, in: windowState) @@ -192,7 +178,7 @@ struct GitHubRepoListView: View { } #Preview { - GitHubRepoListView() + AutopilotRepoListView() .environment(AppState()) .frame(width: 260, height: 400) } diff --git a/RxCodeTests/AppStateTests.swift b/RxCodeTests/AppStateTests.swift index 08c1b4ad..27b0884f 100644 --- a/RxCodeTests/AppStateTests.swift +++ b/RxCodeTests/AppStateTests.swift @@ -533,8 +533,6 @@ private actor MockAppStatePersistence: AppStatePersistenceService { private var deletedSessions: [(projectId: UUID, sessionId: String, origin: SessionOrigin, cwd: String?)] = [] private var runProfiles: [UUID: [RunProfile]] = [:] private var acpClients: [ACPClientSpec] = [] - private var customRepos: [CustomRepo] = [] - private var githubUser: GitHubUser? private var fullSessions: [String: ChatSession] = [:] private var legacySessions: [String: ChatSession] = [:] @@ -617,19 +615,5 @@ private actor MockAppStatePersistence: AppStatePersistenceService { URL(fileURLWithPath: "/tmp/acp_registry.json") } - func saveCustomRepos(_ repos: [CustomRepo]) throws { - customRepos = repos - } - - func loadCustomRepos() -> [CustomRepo] { - customRepos - } - func saveGitHubUser(_ user: GitHubUser) throws { - githubUser = user - } - - func loadGitHubUser() -> GitHubUser? { - githubUser - } }