diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 7a1336f..20a790e 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -14,6 +14,10 @@ 424ACA212CA4697200FA20F2 /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 424ACA202CA4697200FA20F2 /* Credits.rtf */; }; 427C63282C6E868B000E557C /* OpenSettingsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */; }; 42888D512C66B10100DEF835 /* AuthStatusChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42888D502C66B10100DEF835 /* AuthStatusChecker.swift */; }; + 5EC511E32C90CE7400632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; }; + 5EC511E42C90CE9800632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; }; + 5EC511E52C90CFD600632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; }; + 5EC511E62C90CFD700632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; }; C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */; }; C8009C032941C576007AA7E8 /* SyncTextSettingsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C022941C576007AA7E8 /* SyncTextSettingsCommand.swift */; }; C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */; }; @@ -651,6 +655,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5EC511E52C90CFD600632BAB /* Assets.xcassets in Resources */, + 5EC511E32C90CE7400632BAB /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -663,6 +669,7 @@ C8189B1E2938973000C9DCDA /* Assets.xcassets in Resources */, 3ABBEA292C8B9FE100C61D61 /* copilot-language-server in Resources */, 3ABBEA2B2C8BA00300C61D61 /* copilot-language-server-arm64 in Resources */, + 5EC511E62C90CFD700632BAB /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -674,6 +681,7 @@ C861E6152994F6080056CB02 /* Assets.xcassets in Resources */, 3ABBEA2D2C8BA00B00C61D61 /* copilot-language-server in Resources */, C81291D72994FE6900196E12 /* Main.storyboard in Resources */, + 5EC511E42C90CE9800632BAB /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Copilot for Xcode/Assets.xcassets/copilotIcon.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/copilotIcon.imageset/Contents.json new file mode 100644 index 0000000..ae075ee --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/copilotIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "CopilotforXcode-Icon@256w_1x.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/copilotIcon.imageset/CopilotforXcode-Icon@256w_1x.png b/Copilot for Xcode/Assets.xcassets/copilotIcon.imageset/CopilotforXcode-Icon@256w_1x.png new file mode 100644 index 0000000..7674f66 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/copilotIcon.imageset/CopilotforXcode-Icon@256w_1x.png differ diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift deleted file mode 100644 index e641760..0000000 --- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift +++ /dev/null @@ -1,267 +0,0 @@ -import AppKit -import Client -import GitHubCopilotService -import Preferences -import SharedUIComponents -import SuggestionBasic -import SwiftUI - -struct SignInResponse { - let userCode: String - let verificationURL: URL -} - -struct GitHubCopilotView: View { - static var copilotAuthService: GitHubCopilotAuthServiceType? - - class Settings: ObservableObject { - @AppStorage("username") var username: String = "" - @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) - var disableGitHubCopilotSettingsAutoRefreshOnAppear - init() {} - } - - @Environment(\.openURL) var openURL - @Environment(\.toast) var toast - @StateObject var settings = Settings() - - @State var status: GitHubCopilotAccountStatus? - @State var signInResponse: SignInResponse? - @State var version: String? - @State var isRunningAction: Bool = false - @State var isSignInAlertPresented = false - @State var xcodeBetaAccessAlert = false - @State var waitingForSignIn = false - - func getGitHubCopilotAuthService() throws -> GitHubCopilotAuthServiceType { - if let service = Self.copilotAuthService { return service } - let service = try GitHubCopilotService() - Self.copilotAuthService = service - return service - } - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 8) { - Text("Language Server Version: \(version ?? "Loading..")") - .alert(isPresented: $xcodeBetaAccessAlert) { - Alert( - title: Text("Xcode Beta Access Not Granted"), - message: Text( - "Logged in user does not have access to GitHub Copilot for Xcode" - ), - dismissButton: .default(Text("Close")) - ) - } - - if waitingForSignIn { - Text("Status: Waiting for GitHub authentication") - } else { - Text(""" - Status: \(status?.description ?? "Loading..")\ - \(xcodeBetaAccessAlert ? " - Xcode Beta Access Not Granted" : "") - """) - } - - HStack(alignment: .center) { - Button("Refresh") { - checkStatus() - } - if waitingForSignIn { - Button("Cancel") { cancelWaiting() } - } else if status == .notSignedIn { - Button("Sign In") { signIn() } - .alert( - signInResponse?.userCode ?? "", - isPresented: $isSignInAlertPresented, - presenting: signInResponse) { _ in - Button("Cancel", role: .cancel, action: {}) - Button("Copy Code and Open", action: copyAndOpen) - } message: { response in - Text(""" - Please enter the above code in the \ - GitHub website to authorize your \ - GitHub account with Copilot for Xcode. - - \(response?.verificationURL.absoluteString ?? "") - """) - } - } - if status == .ok || status == .alreadySignedIn || - status == .notAuthorized - { - Button("Sign Out") { signOut() } - } - if isRunningAction || waitingForSignIn { - ActivityIndicatorView() - } - } - .opacity(isRunningAction ? 0.8 : 1) - .disabled(isRunningAction) - } - .padding() - - Spacer() - } - .onAppear { - if isPreview { return } - if settings.disableGitHubCopilotSettingsAutoRefreshOnAppear { return } - checkStatus() - } - .textFieldStyle(.roundedBorder) - .onReceive(FeatureFlagNotifierImpl.shared.featureFlagsDidChange) { flags in - self.xcodeBetaAccessAlert = flags.x != true - } - } - - func checkStatus() { - Task { - isRunningAction = true - defer { isRunningAction = false } - do { - let service = try getGitHubCopilotAuthService() - status = try await service.checkStatus() - version = try await service.version() - isRunningAction = false - - if status != .ok, status != .notSignedIn { - toast( - "GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription.", - - .error - ) - } - } catch { - toast(error.localizedDescription, .error) - } - } - } - - func signIn() { - Task { - isRunningAction = true - defer { isRunningAction = false } - do { - let service = try getGitHubCopilotAuthService() - let (uri, userCode) = try await service.signInInitiate() - guard let url = URL(string: uri) else { - toast("Verification URI is incorrect.", .error) - return - } - self.signInResponse = .init(userCode: userCode, verificationURL: url) - isSignInAlertPresented = true - } catch { - toast(error.localizedDescription, .error) - } - } - } - - func copyAndOpen() { - waitingForSignIn = true - guard let signInResponse else { - toast("Missing sign in details.", .error) - return - } - // Copy the device code to the clipboard - let pasteboard = NSPasteboard.general - pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil) - pasteboard.setString(signInResponse.userCode, forType: NSPasteboard.PasteboardType.string) - toast("Sign-in code \(signInResponse.userCode) copied", .info) - // Open verification URL in default browser - openURL(signInResponse.verificationURL) - // Wait for signInConfirm response - waitForSignIn() - } - - func waitForSignIn() { - Task { - do { - guard waitingForSignIn else { return } - guard let signInResponse else { - waitingForSignIn = false - return - } - let service = try getGitHubCopilotAuthService() - let (username, status) = try await service.signInConfirm(userCode: signInResponse.userCode) - waitingForSignIn = false - self.settings.username = username - self.status = status - } catch let error as GitHubCopilotError { - if case .languageServerError(.timeout) = error { - // TODO figure out how to extend the default timeout on an LSP request - // Until then, reissue request - waitForSignIn() - return - } - throw error - } catch { - toast(error.localizedDescription, .error) - } - } - - } - - func cancelWaiting() { - waitingForSignIn = false - } - - func signOut() { - Task { - isRunningAction = true - defer { isRunningAction = false } - do { - let service = try getGitHubCopilotAuthService() - status = try await service.signOut() - } catch { - toast(error.localizedDescription, .error) - } - } - } - - func refreshConfiguration() { - NotificationCenter.default.post( - name: .gitHubCopilotShouldRefreshEditorInformation, - object: nil - ) - - Task { - let service = try getService() - do { - try await service.postNotification( - name: Notification.Name - .gitHubCopilotShouldRefreshEditorInformation.rawValue - ) - } catch { - toast(error.localizedDescription, .error) - } - } - } -} - -struct ActivityIndicatorView: NSViewRepresentable { - func makeNSView(context _: Context) -> NSProgressIndicator { - let progressIndicator = NSProgressIndicator() - progressIndicator.style = .spinning - progressIndicator.controlSize = .small - progressIndicator.startAnimation(nil) - return progressIndicator - } - - func updateNSView(_: NSProgressIndicator, context _: Context) { - // No-op - } -} - -struct CopilotView_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .leading, spacing: 8) { - GitHubCopilotView(status: .notSignedIn, version: "1.0.0") - GitHubCopilotView(status: .alreadySignedIn, isRunningAction: true) - GitHubCopilotView(settings: .init(), status: .alreadySignedIn, xcodeBetaAccessAlert: true) - GitHubCopilotView(settings: .init(), status: .notSignedIn, waitingForSignIn: true) - } - .padding(.all, 8) - .previewLayout(.sizeThatFits) - } -} - diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index d865c15..4fd3d55 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -1,240 +1,395 @@ import Client +import GitHubCopilotService import ComposableArchitecture import KeyboardShortcuts import LaunchAgentManager import Preferences import SharedUIComponents import SwiftUI +import XPCShared +import Cocoa + +struct SignInResponse { + let userCode: String + let verificationURL: URL +} struct GeneralView: View { let store: StoreOf - + @StateObject private var viewModel = GitHubCopilotViewModel() + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { - AppInfoView(store: store) - SettingsDivider() - GitHubCopilotView() - SettingsDivider() - ExtensionServiceView(store: store) - SettingsDivider() - LaunchAgentView() - SettingsDivider() - GeneralSettingsView() + AppInfoView(viewModel: viewModel, store: store) + GeneralSettingsView(store: store) + CopilotConnectionView(viewModel: viewModel, store: store) + .padding(.bottom, 20) + Divider() + Spacer().frame(height: 40) + rightsView + .padding(.horizontal, 20) } + .frame(maxWidth: .infinity) } .onAppear { store.send(.appear) } } + + var rightsView: some View { + Text(StringConstants.rightsReserved) + .font(.caption2) + .foregroundColor(.secondary.opacity(0.5)) + } } struct AppInfoView: View { - @State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + class Settings: ObservableObject { + @AppStorage(\.installPrereleases) + var installPrereleases + } + + static var copilotAuthService: GitHubCopilotAuthServiceType? + @Environment(\.updateChecker) var updateChecker + @Environment(\.toast) var toast + + @StateObject var settings = Settings() + @StateObject var viewModel: GitHubCopilotViewModel + + @State var automaticallyCheckForUpdate: Bool? + @State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + let store: StoreOf - + var body: some View { VStack(alignment: .leading) { - HStack(alignment: .top) { - Text( - Bundle.main - .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String - ?? "GitHub Copilot for Xcode" - ) - .font(.title) - Text(appVersion ?? "") - .font(.footnote) - .foregroundColor(.secondary) - - Spacer() - - Button(action: { - updateChecker.checkForUpdates() - }) { - HStack(spacing: 2) { - Image(systemName: "arrow.up.right.circle.fill") - Text("Check for Updates") - } - } - } - }.padding() - } -} - -struct ExtensionServiceView: View { - @Perception.Bindable var store: StoreOf - - var body: some View { - WithPerceptionTracking { - VStack(alignment: .leading) { - Text("Extension Service Version: \(store.xpcServiceVersion ?? "Loading..")") - - let grantedStatus: String = { - guard let granted = store.isAccessibilityPermissionGranted - else { return "Loading.." } - return granted ? "Granted" : "Not Granted" - }() - Text("Accessibility Permission: \(grantedStatus)") - - HStack { - Button(action: { store.send(.reloadStatus) }) { - Text("Refresh") - }.disabled(store.isReloading) - - Button(action: { - Task { - let workspace = NSWorkspace.shared - let url = Bundle.main.bundleURL - .appendingPathComponent("Contents") - .appendingPathComponent("Applications") - .appendingPathComponent("GitHub Copilot for Xcode Extension.app") - workspace.activateFileViewerSelecting([url]) - } - }) { - Text("Reveal Extension Service in Finder") + HStack(alignment: .center, spacing: 16) { + Image("copilotIcon") + .resizable() + .frame(width: 110, height: 110) + VStack(alignment: .leading) { + HStack { + Text(Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String ?? StringConstants.appName) + .font(.title) + Text("(\(appVersion ?? ""))") + .font(.title) } - + Text("\(StringConstants.languageServerVersion) \(viewModel.version ?? StringConstants.loading)") Button(action: { - let url = URL( - string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - )! - NSWorkspace.shared.open(url) + updateChecker.checkForUpdates() }) { - Text("Accessibility Settings") + HStack(spacing: 2) { + Text(StringConstants.checkForUpdates) + } } - - Button(action: { - let url = URL( - string: "x-apple.systempreferences:com.apple.ExtensionsPreferences" - )! - NSWorkspace.shared.open(url) - }) { - Text("Extensions Settings") + HStack { + Toggle(isOn: .init( + get: { automaticallyCheckForUpdate ?? updateChecker.automaticallyChecksForUpdates }, + set: { + updateChecker.automaticallyChecksForUpdates = $0 + automaticallyCheckForUpdate = $0 + } + )) { + Text(StringConstants.automaticallyCheckForUpdates) + } + + Toggle(isOn: $settings.installPrereleases) { + Text(StringConstants.installPreReleases) + } } } + Spacer() } + .padding(.horizontal, 2) } .padding() + .onAppear { + if isPreview { return } + viewModel.checkStatus() + } } } -struct LaunchAgentView: View { - @Environment(\.toast) var toast - @State var isDidRemoveLaunchAgentAlertPresented = false - @State var isDidSetupLaunchAgentAlertPresented = false - @State var isDidRestartLaunchAgentAlertPresented = false +struct GeneralSettingsView: View { + + class Settings: ObservableObject { + @AppStorage(\.quitXPCServiceOnXcodeAndAppQuit) + var quitXPCServiceOnXcodeAndAppQuit + } + + @StateObject var settings = Settings() + @Environment(\.updateChecker) var updateChecker + @AppStorage(\.realtimeSuggestionToggle) var isCopilotEnabled: Bool + @State private var shouldPresentInstructionSheet = false + @State private var shouldPresentTurnoffSheet = false + + let store: StoreOf + var body: some View { VStack(alignment: .leading) { - HStack { - Button(action: { - Task { - do { - try await LaunchAgentManager().setupLaunchAgent() - isDidSetupLaunchAgentAlertPresented = true - } catch { - toast(error.localizedDescription, .error) - } + Text(StringConstants.general) + .bold() + .padding(.leading, 8) + VStack(spacing: .zero) { + HStack(alignment: .center) { + Text(StringConstants.quitCopilot) + .padding(.horizontal, 8) + Spacer() + Toggle(isOn: $settings.quitXPCServiceOnXcodeAndAppQuit) { } - }) { - Text("Set Up Launch Agent") - } - .alert(isPresented: $isDidSetupLaunchAgentAlertPresented) { - .init( - title: Text("Finished Launch Agent Setup"), - message: Text( - "Please refresh the Copilot status. (The first refresh may fail)" - ), - dismissButton: .default(Text("OK")) - ) + .toggleStyle(.switch) + .padding(.horizontal, 8) } - - Button(action: { - Task { - do { - try await LaunchAgentManager().removeLaunchAgent() - isDidRemoveLaunchAgentAlertPresented = true - } catch { - toast(error.localizedDescription, .error) + .padding(.vertical, 8) + + Divider() + Link(destination: URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) { + HStack { + VStack(alignment: .leading) { + let grantedStatus: String = { + guard let granted = store.isAccessibilityPermissionGranted else { return StringConstants.loading } + return granted ? "Granted" : "Not Granted" + }() + Text(StringConstants.accessibilityPermissions) + .font(.body) + Text("\(StringConstants.status) \(grantedStatus) ⓘ") + .font(.footnote) } + Spacer() + + Image(systemName: "control") + .rotationEffect(.degrees(90)) } - }) { - Text("Remove Launch Agent") } - .alert(isPresented: $isDidRemoveLaunchAgentAlertPresented) { - .init( - title: Text("Launch Agent Removed"), - dismissButton: .default(Text("OK")) - ) + .foregroundStyle(.primary) + .padding(8) + + Divider() + HStack { + VStack(alignment: .leading) { + let grantedStatus: String = { + guard let granted = store.isAccessibilityPermissionGranted else { return StringConstants.loading } + return granted ? "Granted" : "Not Granted" + }() + Text(StringConstants.extensionPermissions) + .font(.body) + Text("\(StringConstants.status) \(grantedStatus) ⓘ") + .font(.footnote) + .onTapGesture { + shouldPresentInstructionSheet = true + } + } + Spacer() + Link(destination: URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences")!) { + Image(systemName: "control") + .rotationEffect(.degrees(90)) + } } - + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 8) + } + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + + HStack(alignment: .center) { + Spacer() Button(action: { - Task { - do { - try await LaunchAgentManager().reloadLaunchAgent() - isDidRestartLaunchAgentAlertPresented = true - } catch { - toast(error.localizedDescription, .error) - } + if isCopilotEnabled { + shouldPresentTurnoffSheet = true + } else { + isCopilotEnabled = true } }) { - Text("Reload Launch Agent") - }.alert(isPresented: $isDidRestartLaunchAgentAlertPresented) { - .init( - title: Text("Launch Agent Reloaded"), - dismissButton: .default(Text("OK")) - ) + Text(isCopilotEnabled ? StringConstants.turnOffCopilot : StringConstants.turnOnCopilot) + .padding(.horizontal, 8) } } } - .padding() + .padding(.horizontal, 20) + .sheet(isPresented: $shouldPresentInstructionSheet) { + } content: { + InstructionSheet { + shouldPresentInstructionSheet = false + } + } + .alert(isPresented: $shouldPresentTurnoffSheet) { + Alert( + title: Text(StringConstants.turnOffAlertTitle), + message: Text(StringConstants.turnOffAlertMessage), + primaryButton: .default(Text("Turn off").foregroundColor(.blue)){ + isCopilotEnabled = false + shouldPresentTurnoffSheet = false + }, + secondaryButton: .cancel(Text(StringConstants.cancel)) { + shouldPresentTurnoffSheet = false + } + ) + } } } -struct GeneralSettingsView: View { - class Settings: ObservableObject { - @AppStorage(\.quitXPCServiceOnXcodeAndAppQuit) - var quitXPCServiceOnXcodeAndAppQuit - @AppStorage(\.suggestionWidgetPositionMode) - var suggestionWidgetPositionMode - @AppStorage(\.widgetColorScheme) - var widgetColorScheme - @AppStorage(\.preferWidgetToStayInsideEditorWhenWidthGreaterThan) - var preferWidgetToStayInsideEditorWhenWidthGreaterThan - @AppStorage(\.showHideWidgetShortcutGlobally) - var showHideWidgetShortcutGlobally - @AppStorage(\.installPrereleases) - var installPrereleases - } - @StateObject var settings = Settings() - @Environment(\.updateChecker) var updateChecker - @State var automaticallyCheckForUpdate: Bool? +struct CopilotConnectionView: View { + @AppStorage("username") var username: String = "" + @Environment(\.toast) var toast + @StateObject var viewModel = GitHubCopilotViewModel() + @State var waitingForSignIn = false + let store: StoreOf + var body: some View { - Form { - Toggle(isOn: $settings.quitXPCServiceOnXcodeAndAppQuit) { - Text("Quit service when Xcode and host app are terminated") + WithPerceptionTracking { + VStack { + connection + .padding(.bottom, 20) + copilotResources } + } + } - Toggle(isOn: .init( - get: { automaticallyCheckForUpdate ?? updateChecker.automaticallyChecksForUpdates }, - set: { - updateChecker.automaticallyChecksForUpdates = $0 - automaticallyCheckForUpdate = $0 + var connection: some View { + VStack(alignment: .leading) { + Text(StringConstants.copilotResources) + .bold() + .padding(.leading, 8) + VStack(spacing: .zero) { + HStack(alignment: .center) { + VStack(alignment: .leading) { + Text(StringConstants.githubAccountStatus) + .font(.body) + Text("\(StringConstants.githubConnection) \(viewModel.status?.description ?? StringConstants.loading)") + .font(.footnote) + } + Spacer() + Button(StringConstants.refreshConnection) { + viewModel.checkStatus() + } + if waitingForSignIn { + Button(StringConstants.cancel) { + viewModel.cancelWaiting() + } + } else if viewModel.status == .notSignedIn { + Button(StringConstants.loginToGitHub) { + viewModel.signIn() + } + .alert( + viewModel.signInResponse?.userCode ?? "", + isPresented: $viewModel.isSignInAlertPresented, + presenting: viewModel.signInResponse) { _ in + Button(StringConstants.cancel, role: .cancel, action: {}) + Button("Copy Code and Open", action: viewModel.copyAndOpen) + } message: { response in + Text(""" + Please enter the above code in the \ + GitHub website to authorize your \ + GitHub account with Copilot for Xcode. + + \(response?.verificationURL.absoluteString ?? "") + """) + } + } + if viewModel.status == .ok || viewModel.status == .alreadySignedIn || + viewModel.status == .notAuthorized + { + Button(StringConstants.logoutFromGitHub) { viewModel.signOut() + viewModel.isSignInAlertPresented = false + } + } + if viewModel.isRunningAction || waitingForSignIn { + ActivityIndicatorView() + } } - )) { - Text("Automatically Check for Updates") + .opacity(viewModel.isRunningAction ? 0.8 : 1) + .disabled(viewModel.isRunningAction) + .padding(8) + + Divider() + Link(destination: URL(string: "https://github.com/settings/copilot")!) { + HStack { + Text(StringConstants.githubCopilotSettings) + .font(.body) + Spacer() + + Image(systemName: "control") + .rotationEffect(.degrees(90)) + } + } + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 10) } + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + } + .padding(.horizontal, 20) + .onAppear { + store.send(.reloadStatus) + viewModel.checkStatus() + } + } + + var copilotResources: some View { + VStack(alignment: .leading) { + Text(StringConstants.copilotResources) + .bold() + .padding(.leading, 8) + + VStack(spacing: .zero) { + let docURL = URL(string: (Bundle.main.object(forInfoDictionaryKey: "COPILOT_DOCS_URL") as? String) ?? "https://docs.github.com/en/copilot")! + Link(destination: docURL) { + HStack { + Text(StringConstants.copilotDocumentation) + .font(.body) + Spacer() + Image(systemName: "control") + .rotationEffect(.degrees(90)) + } + } + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 10) + + Divider() - Toggle(isOn: $settings.installPrereleases) { - Text("Install pre-releases") + let forumURL = URL(string: (Bundle.main.object(forInfoDictionaryKey: "COPILOT_FORUM_URL") as? String) ?? "https://github.com/orgs/community/discussions/categories/copilot")! + Link(destination: forumURL) { + HStack { + Text(StringConstants.copilotFeedbackForum) + .font(.body) + Spacer() + Image(systemName: "control") + .rotationEffect(.degrees(90)) + } + } + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 10) } - }.padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + } + .padding(.horizontal, 20) } } +struct ActivityIndicatorView: NSViewRepresentable { + func makeNSView(context _: Context) -> NSProgressIndicator { + let progressIndicator = NSProgressIndicator() + progressIndicator.style = .spinning + progressIndicator.controlSize = .small + progressIndicator.startAnimation(nil) + return progressIndicator + } + + func updateNSView(_: NSProgressIndicator, context _: Context) { + // No-op + } +} + struct GeneralView_Previews: PreviewProvider { static var previews: some View { GeneralView(store: .init(initialState: .init(), reducer: { General() })) diff --git a/Core/Sources/HostApp/GitHubCopilotViewModel.swift b/Core/Sources/HostApp/GitHubCopilotViewModel.swift new file mode 100644 index 0000000..a06bad3 --- /dev/null +++ b/Core/Sources/HostApp/GitHubCopilotViewModel.swift @@ -0,0 +1,132 @@ +import GitHubCopilotService +import ComposableArchitecture +import KeyboardShortcuts +import LaunchAgentManager +import SwiftUI + + +@MainActor +class GitHubCopilotViewModel: ObservableObject { + @Dependency(\.toast) var toast + @Dependency(\.openURL) var openURL + + @AppStorage("username") var username: String = "" + + @Published var isRunningAction: Bool = false + @Published var status: GitHubCopilotAccountStatus? + @Published var version: String? + @Published var userCode: String? + @Published var isSignInAlertPresented = false + @Published var signInResponse: SignInResponse? + @Published var waitingForSignIn = false + + static var copilotAuthService: GitHubCopilotAuthServiceType? + + func getGitHubCopilotAuthService() throws -> GitHubCopilotAuthServiceType { + if let service = Self.copilotAuthService { return service } + let service = try GitHubCopilotService() + Self.copilotAuthService = service + return service + } + + func signIn() { + Task { + isRunningAction = true + defer { isRunningAction = false } + do { + let service = try getGitHubCopilotAuthService() + let (uri, userCode) = try await service.signInInitiate() + guard let url = URL(string: uri) else { + toast("Verification URI is incorrect.", .error) + return + } + self.signInResponse = .init(userCode: userCode, verificationURL: url) + self.isSignInAlertPresented = true + } catch { + toast(error.localizedDescription, .error) + } + } + } + + func checkStatus() { + Task { + isRunningAction = true + defer { isRunningAction = false } + do { + let service = try getGitHubCopilotAuthService() + status = try await service.checkStatus() + version = try await service.version() + isRunningAction = false + + if status != .ok, status != .notSignedIn { + toast( + "GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription.", + .error + ) + } + } catch { + toast(error.localizedDescription, .error) + } + } + } + + func signOut() { + Task { + isRunningAction = true + defer { isRunningAction = false } + do { + let service = try getGitHubCopilotAuthService() + status = try await service.signOut() + } catch { + toast(error.localizedDescription, .error) + } + } + } + + func cancelWaiting() { + waitingForSignIn = false + } + + func copyAndOpen() { + waitingForSignIn = true + guard let signInResponse else { + toast("Missing sign in details.", .error) + return + } + let pasteboard = NSPasteboard.general + pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil) + pasteboard.setString(signInResponse.userCode, forType: NSPasteboard.PasteboardType.string) + toast("Sign-in code \(signInResponse.userCode) copied", .info) + Task { + await openURL(signInResponse.verificationURL) + waitForSignIn() + } + } + + func waitForSignIn() { + Task { + do { + guard waitingForSignIn else { return } + guard let signInResponse else { + waitingForSignIn = false + return + } + let service = try getGitHubCopilotAuthService() + let (username, status) = try await service.signInConfirm(userCode: signInResponse.userCode) + waitingForSignIn = false + self.username = username + self.status = status + } catch let error as GitHubCopilotError { + if case .languageServerError(.timeout) = error { + // TODO figure out how to extend the default timeout on a Chime LSP request + // Until then, reissue request + waitForSignIn() + return + } + throw error + } catch { + toast(error.localizedDescription, .error) + } + } + } +} diff --git a/Core/Sources/HostApp/InstructionSheet.swift b/Core/Sources/HostApp/InstructionSheet.swift new file mode 100644 index 0000000..c6e6d9a --- /dev/null +++ b/Core/Sources/HostApp/InstructionSheet.swift @@ -0,0 +1,82 @@ +import Foundation +import SwiftUI + +struct InstructionSheet: View { + let closeAction: () -> () + + var body: some View { + VStack(alignment: .center) { + Image("copilotIcon") + .resizable() + .frame(width: 64, height: 64, alignment: .center) + .padding(.top, 36) + .padding(.bottom, 16) + Text("Extension Permissions") + .fontWeight(.heavy) + .font(.system(size: 16)) + Text("To enable permissions in settings:") + .font(.system(size: 14)) + .padding(.top, 4) + + VStack(alignment: .center) { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 3) + .fill(Color.blue) + .frame(width: 13, height: 13) + + Image(systemName: "checkmark") + .foregroundColor(.white) + .font(.system(size: 8, weight: .bold)) + } + + Text("Xcode Source Editor") + .font(.system(size: 12)) + + Image(systemName: "arrowshape.left.fill") + .resizable() + .foregroundColor(Color.red) + .frame(width: 40, height: 10) + } + .frame(height: 25) + .padding(.horizontal, 12) + + HStack { + Image("copilotIcon") + .resizable() + .frame(width: 15, height: 15, alignment: .center) + Text("GitHub Copilot for Xcode") + .font(.system(size: 12)) + } + .frame(height: 25) + .padding(.horizontal, 8) + } + .padding(.vertical, 4) + .background(Color.blue.opacity(0.1)) + .cornerRadius(5) + .padding(.vertical, 10) + + VStack(spacing: 0) { + Text("To view Copilot preferences in XCode, path:") + .font(.system(size: 12)) + .padding(.top, 16) + Text("Xcode Source Editor > GitHub Copilot") + .bold() + .font(.system(size: 12)) + } + .padding(.horizontal) + + Button(action: closeAction, label:{ + Text("Close") + .foregroundColor(.white) + .frame(height: 28) + .frame(maxWidth: .infinity) + }) + .buttonStyle(.borderedProminent) + .cornerRadius(5) + .padding(16) + .padding(.bottom, 16) + } + .frame(width: 300, height: 376) + } +} diff --git a/Core/Sources/HostApp/StringConstants.swift b/Core/Sources/HostApp/StringConstants.swift new file mode 100644 index 0000000..be7976c --- /dev/null +++ b/Core/Sources/HostApp/StringConstants.swift @@ -0,0 +1,30 @@ +struct StringConstants { + static let rightsReserved = "GitHub. All rights reserved." + static let appName = "GitHub Copilot for Xcode" + static let languageServerVersion = "Language Server Version:" + static let checkForUpdates = "Check for Updates" + static let automaticallyCheckForUpdates = "Automatically Check for Updates" + static let installPreReleases = "Install pre-releases" + static let general = "General" + static let quitCopilot = "Quit GitHub Copilot when Xcode App is closed" + static let accessibilityPermissions = "Accessibility Permissions" + static let extensionPermissions = "Extension Permissions" + static let status = "Status:" + static let cancel = "Cancel" + static let turnOff = "Turn off" + static let turnOffCopilot = "Turn off Copilot for Xcode" + static let turnOnCopilot = "Turn on Copilot for Xcode" + static let turnOffAlertTitle = "Turn off Copilot for Xcode" + static let turnOffAlertMessage = "If you turn off Copilot for Xcode, all features will be disabled." + static let githubAccountStatus = "Github Account Status Permissions" + static let githubConnection = "Github Connection:" + static let refreshConnection = "Refresh Connection" + static let loginToGitHub = "Login to GitHub" + static let confirmSignIn = "Confirm Sign-in" + static let logoutFromGitHub = "Logout from GitHub" + static let githubCopilotSettings = "GitHub Copilot Account Settings" + static let copilotResources = "Copilot Resources" + static let copilotDocumentation = "View Copilot Documentation" + static let copilotFeedbackForum = "View Copilot Feedback Forum" + static let loading = "Loading.." +} diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 5602e86..e40db63 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -30,20 +30,18 @@ public struct TabContainer: View { VStack(spacing: 0) { TabBar(tag: $tag, tabBarItems: tabBarItems) .padding(.bottom, 8) - - Divider() - ZStack(alignment: .center) { GeneralView(store: store.scope(state: \.general, action: \.general)) .tabBarItem( tag: 0, title: "General", - image: "gearshape" + image: "CopilotLogo", + isSystemImage: false ) FeatureSettingsView().tabBarItem( tag: 2, title: "Feature", - image: "star.square" + image: "star.circle" ) } .environment(\.tabBarTabTag, tag) @@ -75,7 +73,8 @@ struct TabBar: View { currentTag: $tag, tag: tab.tag, title: tab.title, - image: tab.image + image: tab.image, + isSystemImage: tab.isSystemImage ) } } @@ -88,18 +87,29 @@ struct TabBarButton: View { var tag: Int var title: String var image: String + var isSystemImage: Bool = true + + private var tabImage: Image { + isSystemImage ? Image(systemName: image) : Image(image) + } + + private var isSelected: Bool { + tag == currentTag + } var body: some View { Button(action: { self.currentTag = tag }) { VStack(spacing: 2) { - Image(systemName: image) + tabImage + .renderingMode(.template) .resizable() .scaledToFit() - .frame(height: 18) + .frame(width: 24, height: 24) Text(title) } + .foregroundColor(isSelected ? .blue : .gray) .font(.body) .padding(.horizontal, 12) .padding(.vertical, 4) @@ -129,6 +139,7 @@ private struct TabBarTabViewWrapper: View { var tag: Int var title: String var image: String + var isSystemImage: Bool = true var content: () -> Content var body: some View { @@ -141,7 +152,7 @@ private struct TabBarTabViewWrapper: View { } .preference( key: TabBarItemPreferenceKey.self, - value: [.init(tag: tag, title: title, image: image)] + value: [.init(tag: tag, title: title, image: image, isSystemImage: isSystemImage)] ) } } @@ -150,12 +161,14 @@ private extension View { func tabBarItem( tag: Int, title: String, - image: String + image: String, + isSystemImage: Bool = true ) -> some View { TabBarTabViewWrapper( tag: tag, title: title, image: image, + isSystemImage: isSystemImage, content: { self } ) } @@ -166,6 +179,7 @@ private struct TabBarItem: Identifiable, Equatable { var tag: Int var title: String var image: String + var isSystemImage: Bool = true } private struct TabBarItemPreferenceKey: PreferenceKey { diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index dbbacf9..f2d4c14 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -121,11 +121,15 @@ final class TabToAcceptSuggestion { } func handleEvent(_ event: CGEvent) -> CGEventManipulation.Result { - if Self.shouldAcceptSuggestion( + let (accept, reason) = Self.shouldAcceptSuggestion( event: event, workspacePool: workspacePool, xcodeInspector: ThreadSafeAccessToXcodeInspector.shared - ) { + ) + if let reason = reason { + Logger.service.debug("TabToAcceptSuggestion: \(accept ? "" : "not") accepting due to: \(reason)") + } + if accept { acceptSuggestion() return .discarded } @@ -148,20 +152,30 @@ extension TabToAcceptSuggestion { event: CGEvent, workspacePool: WorkspacePool, xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol - ) -> Bool { + ) -> (accept: Bool, reason: String?) { let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode)) let tab = 48 - guard keycode == tab else { return false } - guard let fileURL = xcodeInspector.activeDocumentURL else { return false } - if event.flags.contains(.maskHelp) { return false } - if event.flags.contains(.maskShift) { return false } - if event.flags.contains(.maskControl) { return false } - if event.flags.contains(.maskCommand) { return false } - guard xcodeInspector.hasActiveXcode else { return false } - guard xcodeInspector.hasFocusedEditor else { return false } - guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL) else { return false } - if filespace.presentingSuggestion == nil { return false } - return true + guard keycode == tab else { return (false, nil) } + if event.flags.contains(.maskHelp) { return (false, nil) } + if event.flags.contains(.maskShift) { return (false, nil) } + if event.flags.contains(.maskControl) { return (false, nil) } + if event.flags.contains(.maskCommand) { return (false, nil) } + guard xcodeInspector.hasActiveXcode else { + return (false, "No active Xcode") + } + guard xcodeInspector.hasFocusedEditor else { + return (false, "No focused editor") + } + guard let fileURL = xcodeInspector.activeDocumentURL else { + return (false, "No active document") + } + guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL) else { + return (false, "No filespace") + } + if filespace.presentingSuggestion == nil { + return (false, "No suggestion") + } + return (true, nil) } } diff --git a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift index 545e488..7046970 100644 --- a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift +++ b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift @@ -15,12 +15,12 @@ class TabToAcceptSuggestionTests: XCTestCase { hasActiveXcode: true, hasFocusedEditor: true ) - XCTAssertTrue( + assertEqual( TabToAcceptSuggestion.shouldAcceptSuggestion( event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ) + ), (true, nil) ) } @@ -28,17 +28,36 @@ class TabToAcceptSuggestionTests: XCTestCase { func test_should_not_accept_without_suggestion() { let fileURL = URL(string: "file:///test")! let workspacePool = FakeWorkspacePool() + workspacePool.setTestFile(fileURL: fileURL, skipSuggestion: true) let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( activeDocumentURL: fileURL, hasActiveXcode: true, hasFocusedEditor: true ) - XCTAssertFalse( + assertEqual( TabToAcceptSuggestion.shouldAcceptSuggestion( event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ) + ), (false, "No suggestion") + ) + } + + @WorkspaceActor + func test_should_not_accept_without_filespace() { + let fileURL = URL(string: "file:///test")! + let workspacePool = FakeWorkspacePool() + let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( + activeDocumentURL: fileURL, + hasActiveXcode: true, + hasFocusedEditor: true + ) + assertEqual( + TabToAcceptSuggestion.shouldAcceptSuggestion( + event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, + workspacePool: workspacePool, + xcodeInspector: xcodeInspector + ), (false, "No filespace") ) } @@ -52,12 +71,12 @@ class TabToAcceptSuggestionTests: XCTestCase { hasActiveXcode: true, hasFocusedEditor: false ) - XCTAssertFalse( + assertEqual( TabToAcceptSuggestion.shouldAcceptSuggestion( event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, workspacePool: workspacePool, xcodeInspector: xcodeInspector - ) + ), (false, "No focused editor") ) } @@ -71,12 +90,12 @@ class TabToAcceptSuggestionTests: XCTestCase { hasActiveXcode: false, hasFocusedEditor: true ) - XCTAssertFalse( + assertEqual( TabToAcceptSuggestion.shouldAcceptSuggestion( event: createEvent(48), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ) + ), (false, "No active Xcode") ) } @@ -90,12 +109,12 @@ class TabToAcceptSuggestionTests: XCTestCase { hasActiveXcode: true, hasFocusedEditor: true ) - XCTAssertFalse( + assertEqual( TabToAcceptSuggestion.shouldAcceptSuggestion( event: createEvent(48), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ) + ), (false, "No active document") ) } @@ -109,12 +128,12 @@ class TabToAcceptSuggestionTests: XCTestCase { hasActiveXcode: true, hasFocusedEditor: true ) - XCTAssertFalse( + assertEqual( TabToAcceptSuggestion.shouldAcceptSuggestion( event: createEvent(48, flags: .maskShift), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ) + ), (false, nil) ) } @@ -128,12 +147,12 @@ class TabToAcceptSuggestionTests: XCTestCase { hasActiveXcode: true, hasFocusedEditor: true ) - XCTAssertFalse( + assertEqual( TabToAcceptSuggestion.shouldAcceptSuggestion( event: createEvent(48, flags: .maskCommand), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ) + ), (false, nil) ) } @@ -147,12 +166,12 @@ class TabToAcceptSuggestionTests: XCTestCase { hasActiveXcode: true, hasFocusedEditor: true ) - XCTAssertFalse( + assertEqual( TabToAcceptSuggestion.shouldAcceptSuggestion( event: createEvent(48, flags: .maskControl), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ) + ), (false, nil) ) } @@ -166,12 +185,12 @@ class TabToAcceptSuggestionTests: XCTestCase { hasActiveXcode: true, hasFocusedEditor: true ) - XCTAssertFalse( + assertEqual( TabToAcceptSuggestion.shouldAcceptSuggestion( event: createEvent(48, flags: .maskHelp), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ) + ), (false, nil) ) } @@ -185,16 +204,25 @@ class TabToAcceptSuggestionTests: XCTestCase { hasActiveXcode: true, hasFocusedEditor: true ) - XCTAssertFalse( + assertEqual( TabToAcceptSuggestion.shouldAcceptSuggestion( event: createEvent(50), workspacePool: workspacePool, xcodeInspector: xcodeInspector - ) + ), (false, nil) ) } } +private func assertEqual( + _ result: (Bool, String?), + _ expected: (Bool, String?) +) { + if result != expected { + XCTFail("Expected \(expected), got \(result)") + } +} + private func createEvent(_ keyCode: CGKeyCode, flags: CGEventFlags = []) -> CGEvent { let event = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true)! event.flags = flags @@ -212,9 +240,10 @@ private class FakeWorkspacePool: WorkspacePool { private var filespace: Filespace? @WorkspaceActor - func setTestFile(fileURL: URL) { + func setTestFile(fileURL: URL, skipSuggestion: Bool = false) { self.fileURL = fileURL self.filespace = Filespace(fileURL: fileURL, onSave: {_ in }, onClose: {_ in }) + if skipSuggestion { return } guard let filespace = self.filespace else { return } filespace.setSuggestions([.init(id: "id", text: "test", position: .zero, range: .zero)]) } diff --git a/README.md b/README.md index cf569b5..341a98a 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ Demo of GitHub Copilot for Xcode -GitHub Copilot for Xcode is macOS application and Xcode extension that enables -using GitHub Copilot code completions in Xcode. +[GitHub Copilot](https://github.com/features/copilot) is an AI pair programmer +that helps you write code faster and smarter. Copilot for Xcode is an +Xcode extension that provides inline coding suggestions as you type. ## Beta Preview Policy @@ -62,6 +63,16 @@ As per [GitHub's Terms of Service](https://docs.github.com/en/github/site-policy This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE.txt) for the full terms. +## Privacy + +Your code is yours. We follow responsible practices in accordance with our +[Privacy Statement](https://docs.github.com/en/site-policy/privacy-policies/github-privacy-statement) +to ensure that your code snippets will not be used as suggested code for other +users of GitHub Copilot. + +To get the latest security fixes, please use the latest version of the GitHub +Copilot for Xcode. + ## Support We’d love to get your help in making GitHub Copilot better! If you have