|
| 1 | +import AppKit |
| 2 | +import Client |
| 3 | +import GitHubCopilotService |
| 4 | +import Preferences |
| 5 | +import SharedUIComponents |
| 6 | +import SuggestionBasic |
| 7 | +import SwiftUI |
| 8 | + |
| 9 | +struct SignInResponse { |
| 10 | + let userCode: String |
| 11 | + let verificationURL: URL |
| 12 | +} |
| 13 | + |
| 14 | +struct GitHubCopilotView: View { |
| 15 | + static var copilotAuthService: GitHubCopilotAuthServiceType? |
| 16 | + |
| 17 | + class Settings: ObservableObject { |
| 18 | + @AppStorage("username") var username: String = "" |
| 19 | + @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) |
| 20 | + var disableGitHubCopilotSettingsAutoRefreshOnAppear |
| 21 | + init() {} |
| 22 | + } |
| 23 | + |
| 24 | + @Environment(\.openURL) var openURL |
| 25 | + @Environment(\.toast) var toast |
| 26 | + @StateObject var settings = Settings() |
| 27 | + |
| 28 | + @State var status: GitHubCopilotAccountStatus? |
| 29 | + @State var signInResponse: SignInResponse? |
| 30 | + @State var version: String? |
| 31 | + @State var isRunningAction: Bool = false |
| 32 | + @State var isSignInAlertPresented = false |
| 33 | + @State var xcodeBetaAccessAlert = false |
| 34 | + @State var waitingForSignIn = false |
| 35 | + |
| 36 | + func getGitHubCopilotAuthService() throws -> GitHubCopilotAuthServiceType { |
| 37 | + if let service = Self.copilotAuthService { return service } |
| 38 | + let service = try GitHubCopilotService() |
| 39 | + Self.copilotAuthService = service |
| 40 | + return service |
| 41 | + } |
| 42 | + |
| 43 | + var body: some View { |
| 44 | + HStack { |
| 45 | + VStack(alignment: .leading, spacing: 8) { |
| 46 | + Text("Language Server Version: \(version ?? "Loading..")") |
| 47 | + .alert(isPresented: $xcodeBetaAccessAlert) { |
| 48 | + Alert( |
| 49 | + title: Text("Xcode Beta Access Not Granted"), |
| 50 | + message: Text( |
| 51 | + "Logged in user does not have access to GitHub Copilot for Xcode" |
| 52 | + ), |
| 53 | + dismissButton: .default(Text("Close")) |
| 54 | + ) |
| 55 | + } |
| 56 | + |
| 57 | + if waitingForSignIn { |
| 58 | + Text("Status: Waiting for GitHub authentication") |
| 59 | + } else { |
| 60 | + Text(""" |
| 61 | + Status: \(status?.description ?? "Loading..")\ |
| 62 | + \(xcodeBetaAccessAlert ? " - Xcode Beta Access Not Granted" : "") |
| 63 | + """) |
| 64 | + } |
| 65 | + |
| 66 | + HStack(alignment: .center) { |
| 67 | + Button("Refresh") { |
| 68 | + checkStatus() |
| 69 | + } |
| 70 | + if waitingForSignIn { |
| 71 | + Button("Cancel") { cancelWaiting() } |
| 72 | + } else if status == .notSignedIn { |
| 73 | + Button("Sign In") { signIn() } |
| 74 | + .alert( |
| 75 | + signInResponse?.userCode ?? "", |
| 76 | + isPresented: $isSignInAlertPresented, |
| 77 | + presenting: signInResponse) { _ in |
| 78 | + Button("Cancel", role: .cancel, action: {}) |
| 79 | + Button("Copy Code and Open", action: copyAndOpen) |
| 80 | + } message: { response in |
| 81 | + Text(""" |
| 82 | + Please enter the above code in the \ |
| 83 | + GitHub website to authorize your \ |
| 84 | + GitHub account with Copilot for Xcode. |
| 85 | + |
| 86 | + \(response?.verificationURL.absoluteString ?? "") |
| 87 | + """) |
| 88 | + } |
| 89 | + } |
| 90 | + if status == .ok || status == .alreadySignedIn || |
| 91 | + status == .notAuthorized |
| 92 | + { |
| 93 | + Button("Sign Out") { signOut() } |
| 94 | + } |
| 95 | + if isRunningAction || waitingForSignIn { |
| 96 | + ActivityIndicatorView() |
| 97 | + } |
| 98 | + } |
| 99 | + .opacity(isRunningAction ? 0.8 : 1) |
| 100 | + .disabled(isRunningAction) |
| 101 | + } |
| 102 | + .padding() |
| 103 | + |
| 104 | + Spacer() |
| 105 | + } |
| 106 | + .onAppear { |
| 107 | + if isPreview { return } |
| 108 | + if settings.disableGitHubCopilotSettingsAutoRefreshOnAppear { return } |
| 109 | + checkStatus() |
| 110 | + } |
| 111 | + .textFieldStyle(.roundedBorder) |
| 112 | + .onReceive(FeatureFlagNotifierImpl.shared.featureFlagsDidChange) { flags in |
| 113 | + self.xcodeBetaAccessAlert = flags.x != true |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + func checkStatus() { |
| 118 | + Task { |
| 119 | + isRunningAction = true |
| 120 | + defer { isRunningAction = false } |
| 121 | + do { |
| 122 | + let service = try getGitHubCopilotAuthService() |
| 123 | + status = try await service.checkStatus() |
| 124 | + version = try await service.version() |
| 125 | + isRunningAction = false |
| 126 | + |
| 127 | + if status != .ok, status != .notSignedIn { |
| 128 | + toast( |
| 129 | + "GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription.", |
| 130 | + |
| 131 | + .error |
| 132 | + ) |
| 133 | + } |
| 134 | + } catch { |
| 135 | + toast(error.localizedDescription, .error) |
| 136 | + } |
| 137 | + } |
| 138 | + } |
| 139 | + |
| 140 | + func signIn() { |
| 141 | + Task { |
| 142 | + isRunningAction = true |
| 143 | + defer { isRunningAction = false } |
| 144 | + do { |
| 145 | + let service = try getGitHubCopilotAuthService() |
| 146 | + let (uri, userCode) = try await service.signInInitiate() |
| 147 | + guard let url = URL(string: uri) else { |
| 148 | + toast("Verification URI is incorrect.", .error) |
| 149 | + return |
| 150 | + } |
| 151 | + self.signInResponse = .init(userCode: userCode, verificationURL: url) |
| 152 | + isSignInAlertPresented = true |
| 153 | + } catch { |
| 154 | + toast(error.localizedDescription, .error) |
| 155 | + } |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + func copyAndOpen() { |
| 160 | + waitingForSignIn = true |
| 161 | + guard let signInResponse else { |
| 162 | + toast("Missing sign in details.", .error) |
| 163 | + return |
| 164 | + } |
| 165 | + // Copy the device code to the clipboard |
| 166 | + let pasteboard = NSPasteboard.general |
| 167 | + pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil) |
| 168 | + pasteboard.setString(signInResponse.userCode, forType: NSPasteboard.PasteboardType.string) |
| 169 | + toast("Sign-in code \(signInResponse.userCode) copied", .info) |
| 170 | + // Open verification URL in default browser |
| 171 | + openURL(signInResponse.verificationURL) |
| 172 | + // Wait for signInConfirm response |
| 173 | + waitForSignIn() |
| 174 | + } |
| 175 | + |
| 176 | + func waitForSignIn() { |
| 177 | + Task { |
| 178 | + do { |
| 179 | + guard waitingForSignIn else { return } |
| 180 | + guard let signInResponse else { |
| 181 | + waitingForSignIn = false |
| 182 | + return |
| 183 | + } |
| 184 | + let service = try getGitHubCopilotAuthService() |
| 185 | + let (username, status) = try await service.signInConfirm(userCode: signInResponse.userCode) |
| 186 | + waitingForSignIn = false |
| 187 | + self.settings.username = username |
| 188 | + self.status = status |
| 189 | + } catch let error as GitHubCopilotError { |
| 190 | + if case .languageServerError(.timeout) = error { |
| 191 | + // TODO figure out how to extend the default timeout on an LSP request |
| 192 | + // Until then, reissue request |
| 193 | + waitForSignIn() |
| 194 | + return |
| 195 | + } |
| 196 | + throw error |
| 197 | + } catch { |
| 198 | + toast(error.localizedDescription, .error) |
| 199 | + } |
| 200 | + } |
| 201 | + |
| 202 | + } |
| 203 | + |
| 204 | + func cancelWaiting() { |
| 205 | + waitingForSignIn = false |
| 206 | + } |
| 207 | + |
| 208 | + func signOut() { |
| 209 | + Task { |
| 210 | + isRunningAction = true |
| 211 | + defer { isRunningAction = false } |
| 212 | + do { |
| 213 | + let service = try getGitHubCopilotAuthService() |
| 214 | + status = try await service.signOut() |
| 215 | + } catch { |
| 216 | + toast(error.localizedDescription, .error) |
| 217 | + } |
| 218 | + } |
| 219 | + } |
| 220 | + |
| 221 | + func refreshConfiguration() { |
| 222 | + NotificationCenter.default.post( |
| 223 | + name: .gitHubCopilotShouldRefreshEditorInformation, |
| 224 | + object: nil |
| 225 | + ) |
| 226 | + |
| 227 | + Task { |
| 228 | + let service = try getService() |
| 229 | + do { |
| 230 | + try await service.postNotification( |
| 231 | + name: Notification.Name |
| 232 | + .gitHubCopilotShouldRefreshEditorInformation.rawValue |
| 233 | + ) |
| 234 | + } catch { |
| 235 | + toast(error.localizedDescription, .error) |
| 236 | + } |
| 237 | + } |
| 238 | + } |
| 239 | +} |
| 240 | + |
| 241 | +struct ActivityIndicatorView: NSViewRepresentable { |
| 242 | + func makeNSView(context _: Context) -> NSProgressIndicator { |
| 243 | + let progressIndicator = NSProgressIndicator() |
| 244 | + progressIndicator.style = .spinning |
| 245 | + progressIndicator.controlSize = .small |
| 246 | + progressIndicator.startAnimation(nil) |
| 247 | + return progressIndicator |
| 248 | + } |
| 249 | + |
| 250 | + func updateNSView(_: NSProgressIndicator, context _: Context) { |
| 251 | + // No-op |
| 252 | + } |
| 253 | +} |
| 254 | + |
| 255 | +struct CopilotView_Previews: PreviewProvider { |
| 256 | + static var previews: some View { |
| 257 | + VStack(alignment: .leading, spacing: 8) { |
| 258 | + GitHubCopilotView(status: .notSignedIn, version: "1.0.0") |
| 259 | + GitHubCopilotView(status: .alreadySignedIn, isRunningAction: true) |
| 260 | + GitHubCopilotView(settings: .init(), status: .alreadySignedIn, xcodeBetaAccessAlert: true) |
| 261 | + GitHubCopilotView(settings: .init(), status: .notSignedIn, waitingForSignIn: true) |
| 262 | + } |
| 263 | + .padding(.all, 8) |
| 264 | + .previewLayout(.sizeThatFits) |
| 265 | + } |
| 266 | +} |
| 267 | + |
0 commit comments