diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8ce1fa5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve GitHub Copilot for Xcode +--- + + + +**Describe the bug** + + +**Versions** +- Copilot for Xcode: [e.g. 0.25.0] +- Xcode: [e.g. 16.0] +- macOS: [e.g. 14.6.1] + +**Steps to reproduce** +1. +2. + +**Screenshots** + + +**Logs** + + +**Additional context** + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..3f98d70 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,8 @@ +--- +name: Feature request +about: Suggest an idea for GitHub Copilot for Xcode +--- + + + + \ No newline at end of file diff --git a/CommunicationBridge/main.swift b/CommunicationBridge/main.swift index 0cac3a3..b420b80 100644 --- a/CommunicationBridge/main.swift +++ b/CommunicationBridge/main.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import Logger class AppDelegate: NSObject, NSApplicationDelegate {} @@ -15,5 +16,6 @@ listener.delegate = delegate listener.resume() let app = NSApplication.shared app.delegate = appDelegate +Logger.communicationBridge.info("Communication bridge started") app.run() diff --git a/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist index 05fd996..b45f6d1 100644 --- a/Copilot-for-Xcode-Info.plist +++ b/Copilot-for-Xcode-Info.plist @@ -2,7 +2,7 @@ - + APP_ID_PREFIX $(AppIdentifierPrefix) APPLICATION_SUPPORT_FOLDER $(APPLICATION_SUPPORT_FOLDER) diff --git a/Core/Package.swift b/Core/Package.swift index 159138e..1d08edb 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -209,7 +209,12 @@ let package = Package( // MARK: - Helpers .target(name: "FileChangeChecker"), - .target(name: "LaunchAgentManager"), + .target( + name: "LaunchAgentManager", + dependencies: [ + .product(name: "Logger", package: "Tool"), + ] + ), .target( name: "UpdateChecker", dependencies: [ diff --git a/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift b/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift new file mode 100644 index 0000000..384daad --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/AdvancedSettings.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct AdvancedSettings: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 30) { + SuggestionSection() + EnterpriseSection() + ProxySection() + LoggingSection() + } + .padding(20) + } + } +} + +#Preview { + AdvancedSettings() + .frame(width: 800, height: 600) +} diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift similarity index 86% rename from Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift rename to Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift index 3a2db0d..cec78ed 100644 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift +++ b/Core/Sources/HostApp/AdvancedSettings/DisabledLanguageList.swift @@ -15,7 +15,7 @@ extension List { } } -struct SuggestionFeatureDisabledLanguageListView: View { +struct DisabledLanguageList: View { final class Settings: ObservableObject { @AppStorage(\.suggestionFeatureDisabledLanguageList) var suggestionFeatureDisabledLanguageList: [String] @@ -100,16 +100,15 @@ struct SuggestionFeatureDisabledLanguageListView: View { } } -struct SuggestionFeatureDisabledLanguageListView_Preview: PreviewProvider { - static var previews: some View { - SuggestionFeatureDisabledLanguageListView( - isOpen: .constant(true), - settings: .init(suggestionFeatureDisabledLanguageList: .init(wrappedValue: [ - "hello/2", - "hello/3", - "hello/4", - ], "SuggestionFeatureDisabledLanguageListView_Preview")) - ) - } +#Preview { + DisabledLanguageList( + isOpen: .constant(true), + settings: .init(suggestionFeatureDisabledLanguageList: .init(wrappedValue: [ + "hello/2", + "hello/3", + "hello/4", + ], "SuggestionFeatureDisabledLanguageListView_Preview")) + ) } + diff --git a/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift b/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift new file mode 100644 index 0000000..ee90c35 --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/EnterpriseSection.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct EnterpriseSection: View { + @AppStorage(\.gitHubCopilotEnterpriseURI) var gitHubCopilotEnterpriseURI + + var body: some View { + SettingsSection(title: "Enterprise") { + SettingsTextField( + title: "Auth provider URL", + prompt: "Leave it blank if none is available.", + text: $gitHubCopilotEnterpriseURI + ) + } + } +} + +#Preview { + EnterpriseSection() +} diff --git a/Core/Sources/HostApp/AdvancedSettings/LoggingSection.swift b/Core/Sources/HostApp/AdvancedSettings/LoggingSection.swift new file mode 100644 index 0000000..113c2e4 --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/LoggingSection.swift @@ -0,0 +1,52 @@ +import Logger +import SwiftUI + +struct LoggingSection: View { + @AppStorage(\.verboseLoggingEnabled) var verboseLoggingEnabled: Bool + @State private var shouldPresentRestartAlert = false + + var verboseLoggingBinding: Binding { + Binding( + get: { verboseLoggingEnabled }, + set: { + verboseLoggingEnabled = $0 + shouldPresentRestartAlert = $0 + } + ) + } + + var body: some View { + SettingsSection(title: "Logging") { + SettingsToggle( + title: "Verbose Logging", + isOn: verboseLoggingBinding + ) + Divider() + SettingsLink( + URL(fileURLWithPath: FileLoggingLocation.path.string), + title: "Open Copilot Log Folder" + ) + .environment(\.openURL, OpenURLAction { url in + NSWorkspace.shared.open(url) + return .handled + }) + } + .alert(isPresented: $shouldPresentRestartAlert) { + Alert( + title: Text("Quit And Restart Xcode"), + message: Text( + """ + Logging level changes will take effect the next time Copilot \ + for Xcode is started. To update logging now, please quit \ + Copilot for Xcode and restart Xcode. + """ + ), + dismissButton: .default(Text("OK")) + ) + } + } +} + +#Preview { + LoggingSection() +} diff --git a/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift b/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift new file mode 100644 index 0000000..27b1ac2 --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/ProxySection.swift @@ -0,0 +1,65 @@ +import Client +import SwiftUI +import Toast + +struct ProxySection: View { + @AppStorage(\.gitHubCopilotProxyUrl) var gitHubCopilotProxyUrl + @AppStorage(\.gitHubCopilotProxyUsername) var gitHubCopilotProxyUsername + @AppStorage(\.gitHubCopilotProxyPassword) var gitHubCopilotProxyPassword + @AppStorage(\.gitHubCopilotUseStrictSSL) var gitHubCopilotUseStrictSSL + + @Environment(\.toast) var toast + + var body: some View { + SettingsSection(title: "Proxy") { + SettingsTextField( + title: "Proxy URL", + prompt: "http://host:port", + text: $gitHubCopilotProxyUrl + ) + SettingsTextField( + title: "Proxy username", + prompt: "username", + text: $gitHubCopilotProxyUsername + ) + SettingsSecureField( + title: "Proxy password", + prompt: "password", + text: $gitHubCopilotProxyPassword + ) + SettingsToggle( + title: "Proxy strict SSL", + isOn: $gitHubCopilotUseStrictSSL + ) + } footer: { + HStack { + Spacer() + Button("Refresh configurations") { + refreshConfiguration() + } + } + } + } + + 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) + } + } + } +} + +#Preview { + ProxySection() +} diff --git a/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift b/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift new file mode 100644 index 0000000..cb86bde --- /dev/null +++ b/Core/Sources/HostApp/AdvancedSettings/SuggestionSection.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct SuggestionSection: View { + @AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle + @AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList + @AppStorage(\.acceptSuggestionWithTab) var acceptSuggestionWithTab + @State var isSuggestionFeatureDisabledLanguageListViewOpen = false + @State private var shouldPresentTurnoffSheet = false + + var realtimeSuggestionBinding : Binding { + Binding( + get: { realtimeSuggestionToggle }, + set: { + if !$0 { + shouldPresentTurnoffSheet = true + } else { + realtimeSuggestionToggle = $0 + } + } + ) + } + + var body: some View { + SettingsSection(title: "Suggestion Settings") { + SettingsToggle( + title: "Request suggestions while typing", + isOn: realtimeSuggestionBinding + ) + Divider() + SettingsToggle( + title: "Accept suggestions with Tab", + isOn: $acceptSuggestionWithTab + ) + } footer: { + HStack { + Spacer() + Button("Disabled language list") { + isSuggestionFeatureDisabledLanguageListViewOpen = true + } + } + } + .sheet(isPresented: $isSuggestionFeatureDisabledLanguageListViewOpen) { + DisabledLanguageList(isOpen: $isSuggestionFeatureDisabledLanguageListViewOpen) + } + .alert( + "Disable suggestions while typing", + isPresented: $shouldPresentTurnoffSheet + ) { + Button("Disable") { realtimeSuggestionToggle = false } + Button("Cancel", role: .cancel, action: {}) + } message: { + Text(""" + If you disable requesting suggestions while typing, you will \ + not see any suggestions until requested manually. + """) + } + } +} + +#Preview { + SuggestionSection() +} diff --git a/Core/Sources/HostApp/FeatureSettings/LoggingSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/LoggingSettingsView.swift deleted file mode 100644 index da311de..0000000 --- a/Core/Sources/HostApp/FeatureSettings/LoggingSettingsView.swift +++ /dev/null @@ -1,63 +0,0 @@ -import AppKit -import Logger -import Preferences -import SwiftUI - -struct LoggingSettingsView: View { - @AppStorage(\.verboseLoggingEnabled) var verboseLoggingEnabled: Bool - @State private var shouldPresentRestartAlert = false - - var body: some View { - VStack(alignment: .leading) { - Text("Logging") - .bold() - .padding(.leading, 8) - VStack(spacing: .zero) { - HStack(alignment: .center) { - Text("Verbose Logging") - .padding(.horizontal, 8) - Spacer() - Toggle(isOn: $verboseLoggingEnabled) { - } - .toggleStyle(.switch) - .padding(.horizontal, 8) - } - .padding(.vertical, 8) - .onChange(of: verboseLoggingEnabled) { _ in - shouldPresentRestartAlert = true - } - - Divider() - - HStack { - Text("Open Copilot Log Folder") - .font(.body) - Spacer() - Image(systemName: "chevron.right") - } - .onTapGesture { - NSWorkspace.shared.open(URL(fileURLWithPath: FileLoggingLocation.path.string, isDirectory: true)) - } - .foregroundStyle(.primary) - .padding(.horizontal, 8) - .padding(.vertical, 10) - } - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - } - .padding(.horizontal, 20) - .alert(isPresented: $shouldPresentRestartAlert) { - Alert( - title: Text("Quit And Restart Xcode"), - message: Text( - """ - Logging level changes will take effect the next time Copilot \ - for Xcode is started. To update logging now, please quit \ - Copilot for Xcode and restart Xcode. - """ - ), - dismissButton: .default(Text("OK")) - ) - } - } -} diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggesionSettingProxyView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggesionSettingProxyView.swift deleted file mode 100644 index 9388cbb..0000000 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggesionSettingProxyView.swift +++ /dev/null @@ -1,131 +0,0 @@ -import Preferences -import SharedUIComponents -import SwiftUI -import XPCShared -import Toast -import Client - -struct SuggesionSettingProxyView: View { - class Settings: ObservableObject { - @AppStorage("username") var username: String = "" - @AppStorage(\.gitHubCopilotProxyUrl) var gitHubCopilotProxyUrl - @AppStorage(\.gitHubCopilotProxyUsername) var gitHubCopilotProxyUsername - @AppStorage(\.gitHubCopilotProxyPassword) var gitHubCopilotProxyPassword - @AppStorage(\.gitHubCopilotUseStrictSSL) var gitHubCopilotUseStrictSSL - @AppStorage(\.gitHubCopilotEnterpriseURI) var gitHubCopilotEnterpriseURI - } - - @StateObject var settings = Settings() - @Environment(\.toast) var toast - - var body: some View { - VStack(alignment: .leading) { - Text(StringConstants.enterprise) - .bold() - .padding(.leading, 8) - - Form { - TextField( - text: $settings.gitHubCopilotEnterpriseURI, - prompt: Text(StringConstants.leaveBlankPrompt) - ) { - Text(StringConstants.authProviderURL) - } - .textFieldStyle(PlainTextFieldStyle()) - .multilineTextAlignment(.trailing) - } - .padding(8) - .background(Color.gray.opacity(0.1)) - .cornerRadius(6) - .padding(.bottom, 16) - - Text(StringConstants.proxy) - .bold() - .padding(.leading, 8) - - VStack(spacing: 0) { - Form { - TextField( - text: $settings.gitHubCopilotProxyUrl, - prompt: Text(StringConstants.proxyURLPrompt) - ) { - Text(StringConstants.proxyURL) - } - .textFieldStyle(PlainTextFieldStyle()) - .multilineTextAlignment(.trailing) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - - Divider() - - Form { - TextField(text: $settings.gitHubCopilotProxyUsername, prompt: Text(StringConstants.proxyUsernamePrompt)) { - Text(StringConstants.proxyUsername) - } - .textFieldStyle(PlainTextFieldStyle()) - .multilineTextAlignment(.trailing) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - - Divider() - - Form { - SecureField(text: $settings.gitHubCopilotProxyPassword, prompt: Text(StringConstants.proxyPasswordPrompt)) { - Text(StringConstants.proxyPassword) - } - .textFieldStyle(PlainTextFieldStyle()) - .multilineTextAlignment(.trailing) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - - Divider() - - HStack { - Text(StringConstants.proxyStrictSSL) - Spacer() - Toggle("", isOn: $settings.gitHubCopilotUseStrictSSL) - .toggleStyle(.switch) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - .background(Color.gray.opacity(0.1)) - .cornerRadius(6) - .padding(.bottom, 8) - - HStack { - Spacer() - Button(StringConstants.refreshConfigurations) { - refreshConfiguration() - } - } - .padding(.horizontal, 16) - Spacer() - } - .padding(16) - } - 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) - } - } - } -} - -#Preview { - SuggesionSettingProxyView() -} diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift deleted file mode 100644 index f57cd5e..0000000 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift +++ /dev/null @@ -1,140 +0,0 @@ -import SharedUIComponents -import SwiftUI - -struct SuggestionFeatureEnabledProjectListView: View { - final class Settings: ObservableObject { - @AppStorage(\.suggestionFeatureEnabledProjectList) - var suggestionFeatureEnabledProjectList: [String] - - init(suggestionFeatureEnabledProjectList: AppStorage<[String]>? = nil) { - if let list = suggestionFeatureEnabledProjectList { - _suggestionFeatureEnabledProjectList = list - } - } - } - - var isOpen: Binding - @State var isAddingNewProject = false - @StateObject var settings = Settings() - - var body: some View { - VStack(spacing: 0) { - HStack { - Button(action: { - self.isOpen.wrappedValue = false - }) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - .padding() - } - .buttonStyle(.plain) - Text("Enabled Projects") - Spacer() - Button(action: { - isAddingNewProject = true - }) { - Image(systemName: "plus.circle.fill") - .foregroundStyle(.secondary) - .padding() - } - .buttonStyle(.plain) - } - .background(Color(nsColor: .separatorColor)) - - List { - ForEach( - settings.suggestionFeatureEnabledProjectList, - id: \.self - ) { project in - HStack { - Text(project) - .contextMenu { - Button("Remove") { - settings.suggestionFeatureEnabledProjectList.removeAll( - where: { $0 == project } - ) - } - } - Spacer() - - Button(action: { - settings.suggestionFeatureEnabledProjectList.removeAll( - where: { $0 == project } - ) - }) { - Image(systemName: "trash.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - } - .modify { view in - if #available(macOS 13.0, *) { - view.listRowSeparator(.hidden).listSectionSeparator(.hidden) - } else { - view - } - } - } - .removeBackground() - .overlay { - if settings.suggestionFeatureEnabledProjectList.isEmpty { - Text(""" - Empty - Add project with "+" button - Or right clicking the circular widget - """) - .multilineTextAlignment(.center) - } - } - } - .focusable(false) - .frame(width: 300, height: 400) - .background(Color(nsColor: .windowBackgroundColor)) - .sheet(isPresented: $isAddingNewProject) { - SuggestionFeatureAddEnabledProjectView(isOpen: $isAddingNewProject, settings: settings) - } - } -} - -struct SuggestionFeatureAddEnabledProjectView: View { - var isOpen: Binding - var settings: SuggestionFeatureEnabledProjectListView.Settings - @State var rootPath = "" - - var body: some View { - VStack { - Text( - "Enter the root path of the project. Do not use `~` to replace /Users/yourUserName." - ) - TextField("Root path", text: $rootPath) - HStack { - Spacer() - Button("Cancel") { - isOpen.wrappedValue = false - } - Button("Add") { - settings.suggestionFeatureEnabledProjectList.append(rootPath) - isOpen.wrappedValue = false - } - } - } - .padding() - .frame(minWidth: 500) - .background(Color(nsColor: .windowBackgroundColor)) - } -} - -struct SuggestionFeatureEnabledProjectListView_Preview: PreviewProvider { - static var previews: some View { - SuggestionFeatureEnabledProjectListView( - isOpen: .constant(true), - settings: .init(suggestionFeatureEnabledProjectList: .init(wrappedValue: [ - "hello/2", - "hello/3", - "hello/4", - ], "SuggestionFeatureEnabledProjectListView_Preview")) - ) - } -} - diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift deleted file mode 100644 index debc7c8..0000000 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Client -import Preferences -import SharedUIComponents -import SwiftUI -import XPCShared - -struct SuggestionSettingsGeneralSectionView: View { - final class Settings: ObservableObject { - @AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle - @AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList - @AppStorage(\.acceptSuggestionWithTab) var acceptSuggestionWithTab - } - - @StateObject var settings = Settings() - @State var isSuggestionFeatureDisabledLanguageListViewOpen = false - - var body: some View { - VStack(alignment: .leading) { - Text(StringConstants.suggestionSettings) - .bold() - .padding(.leading, 8) - - VStack(spacing: .zero) { - HStack(alignment: .center) { - Text(StringConstants.requestSuggestionsInRealTime) - .padding(.horizontal, 8) - Spacer() - Toggle(isOn: $settings.realtimeSuggestionToggle) { - } - .toggleStyle(SwitchToggleStyle(tint: .blue)) - .padding(.horizontal, 8) - } - .padding(.vertical, 8) - - Divider() - - HStack(alignment: .center) { - Text(StringConstants.acceptSuggestionsWithTab) - .padding(.horizontal, 8) - Spacer() - Toggle(isOn: $settings.acceptSuggestionWithTab) { - } - .toggleStyle(SwitchToggleStyle(tint: .blue)) - .padding(.horizontal, 8) - } - .padding(.vertical, 8) - } - .background(Color.gray.opacity(0.1)) - .cornerRadius(6) - .padding(.bottom, 8) - - HStack { - Spacer() - Button(StringConstants.disabledLanguageList) { - isSuggestionFeatureDisabledLanguageListViewOpen = true - } - } - .padding(.horizontal) - .sheet(isPresented: $isSuggestionFeatureDisabledLanguageListViewOpen) { - SuggestionFeatureDisabledLanguageListView(isOpen: $isSuggestionFeatureDisabledLanguageListViewOpen) - } - Spacer() - } - .padding(16) - } -} - -#Preview { - SuggestionSettingsGeneralSectionView() - .padding() -} - diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift deleted file mode 100644 index 8d2f05a..0000000 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Client -import Preferences -import SharedUIComponents -import SwiftUI -import XPCShared - -struct SuggestionSettingsView: View { - var body: some View { - ScrollView { - SuggestionSettingsGeneralSectionView() - SuggesionSettingProxyView() - LoggingSettingsView() - }.padding() - } -} - -struct SuggestionSettingsView_Previews: PreviewProvider { - static var previews: some View { - SuggestionSettingsView() - .frame(width: 600, height: 500) - } -} - diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift deleted file mode 100644 index bee029b..0000000 --- a/Core/Sources/HostApp/FeatureSettingsView.swift +++ /dev/null @@ -1,21 +0,0 @@ -import SwiftUI - -struct FeatureSettingsView: View { - var body: some View { - SuggestionSettingsView() - .sidebarItem( - tag: 0, - title: "Suggestion", - subtitle: "Generate suggestions for your code", - image: "lightbulb" - ) - } -} - -struct FeatureSettingsView_Previews: PreviewProvider { - static var previews: some View { - FeatureSettingsView() - .frame(width: 800) - } -} - diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index 53d37b8..98578f6 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -4,6 +4,7 @@ import Foundation import LaunchAgentManager import SwiftUI import XPCShared +import Logger @Reducer struct General { @@ -21,6 +22,7 @@ struct General { case reloadStatus case finishReloading(xpcServiceVersion: String, permissionGranted: Bool) case failedReloading + case retryReloading } @Dependency(\.toast) var toast @@ -32,29 +34,29 @@ struct General { switch action { case .appear: return .run { send in - Task { - for await _ in DistributedNotificationCenter.default().notifications(named: NSNotification.Name("com.apple.accessibility.api")) { - await send(.reloadStatus) - } - } await send(.setupLaunchAgentIfNeeded) + for await _ in DistributedNotificationCenter.default().notifications(named: NSNotification.Name("com.apple.accessibility.api")) { + await send(.reloadStatus) + } } case .setupLaunchAgentIfNeeded: return .run { send in #if DEBUG // do not auto install on debug build + await send(.reloadStatus) #else Task { do { try await LaunchAgentManager() .setupLaunchAgentForTheFirstTimeIfNeeded() } catch { + Logger.ui.error("Failed to setup launch agent. \(error.localizedDescription)") toast(error.localizedDescription, .error) } + await send(.reloadStatus) } #endif - await send(.reloadStatus) } case .openExtensionManager: @@ -70,6 +72,7 @@ struct General { } case .reloadStatus: + guard !state.isReloading else { return .none } state.isReloading = true return .run { send in let service = try getService() @@ -86,15 +89,17 @@ struct General { } else { toast("Launching service app.", .info) try await Task.sleep(nanoseconds: 5_000_000_000) - await send(.reloadStatus) + await send(.retryReloading) } } catch let error as XPCCommunicationBridgeError { + Logger.ui.error("Failed to reach communication bridge. \(error.localizedDescription)") toast( "Failed to reach communication bridge. \(error.localizedDescription)", .error ) await send(.failedReloading) } catch { + Logger.ui.error("Failed to reload status. \(error.localizedDescription)") toast(error.localizedDescription, .error) await send(.failedReloading) } @@ -109,6 +114,12 @@ struct General { case .failedReloading: state.isReloading = false return .none + + case .retryReloading: + state.isReloading = false + return .run { send in + await send(.reloadStatus) + } } } } diff --git a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift new file mode 100644 index 0000000..643266b --- /dev/null +++ b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift @@ -0,0 +1,77 @@ +import ComposableArchitecture +import GitHubCopilotService +import SwiftUI + +struct AppInfoView: View { + 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 { + HStack(alignment: .center, spacing: 16) { + let appImage = if let nsImage = NSImage(named: "AppIcon") { + Image(nsImage: nsImage) + } else { + Image(systemName: "app") + } + appImage + .resizable() + .frame(width: 110, height: 110) + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String ?? "GitHub Copilot for Xcode") + .font(.title) + Text("(\(appVersion ?? ""))") + .font(.title) + } + Text("Language Server Version: \(viewModel.version ?? "Loading...")") + Button(action: { + updateChecker.checkForUpdates() + }) { + HStack(spacing: 2) { + Text("Check for Updates") + } + } + HStack { + Toggle(isOn: .init( + get: { automaticallyCheckForUpdate ?? updateChecker.automaticallyChecksForUpdates }, + set: { + updateChecker.automaticallyChecksForUpdates = $0 + automaticallyCheckForUpdate = $0 + } + )) { + Text("Automatically Check for Updates") + } + + Toggle(isOn: $settings.installPrereleases) { + Text("Install pre-releases") + } + } + } + Spacer() + } + .padding(.horizontal, 2) + .padding(.vertical, 15) + } +} + +#Preview { + AppInfoView( + viewModel: .init(), + store: .init(initialState: .init(), reducer: { General() }) + ) +} diff --git a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift new file mode 100644 index 0000000..828f492 --- /dev/null +++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift @@ -0,0 +1,122 @@ +import ComposableArchitecture +import SwiftUI + +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 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 { + WithPerceptionTracking { + VStack { + connection + .padding(.bottom, 20) + copilotResources + } + } + } + + var accountStatus: some View { + SettingsButtonRow( + title: "GitHub Account Status Permissions", + subtitle: "GitHub Connection: \(viewModel.status?.description ?? "Loading...")" + ) { + Button("Refresh Connection") { + viewModel.checkStatus() + } + if waitingForSignIn { + Button("Cancel") { + viewModel.cancelWaiting() + } + } else if viewModel.status == .notSignedIn { + Button("Login to GitHub") { + viewModel.signIn() + } + .alert( + viewModel.signInResponse?.userCode ?? "", + isPresented: $viewModel.isSignInAlertPresented, + presenting: viewModel.signInResponse) { _ in + Button("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("Logout from GitHub") { viewModel.signOut() + viewModel.isSignInAlertPresented = false + } + } + if viewModel.isRunningAction || waitingForSignIn { + ActivityIndicatorView() + } + } + } + + var connection: some View { + SettingsSection(title: "Copilot Connection") { + accountStatus + Divider() + SettingsLink( + url: "https://github.com/settings/copilot", + title: "GitHub Copilot Account Settings" + ) + } + } + + var copilotResources: some View { + SettingsSection(title: "Copilot Resources") { + SettingsLink( + url: "https://docs.github.com/en/copilot", + title: "View Copilot Documentation" + ) + Divider() + SettingsLink( + url: "https://github.com/orgs/community/discussions/categories/copilot", + title: "View Copilot Feedback Forum" + ) + } + } +} + + +#Preview { + CopilotConnectionView( + viewModel: .init(), + store: .init(initialState: .init(), reducer: { General() }) + ) +} + +#Preview("Running") { + let runningModel = GitHubCopilotViewModel() + runningModel.isRunningAction = true + return CopilotConnectionView( + viewModel: runningModel, + store: .init(initialState: .init(), reducer: { General() }) + ) +} diff --git a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift new file mode 100644 index 0000000..63e6092 --- /dev/null +++ b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift @@ -0,0 +1,63 @@ +import ComposableArchitecture +import SwiftUI + +struct GeneralSettingsView: View { + @Environment(\.updateChecker) var updateChecker + @AppStorage(\.extensionPermissionShown) var extensionPermissionShown: Bool + @AppStorage(\.quitXPCServiceOnXcodeAndAppQuit) var quitXPCServiceOnXcodeAndAppQuit: Bool + @State private var shouldPresentExtensionPermissionAlert = false + + let store: StoreOf + + var accessibilityPermissionSubtitle: String { + guard let granted = store.isAccessibilityPermissionGranted else { return "Loading..." } + return granted ? "Granted" : "Not Granted. Required to run. Click to open System Preferences." + } + + var body: some View { + SettingsSection(title: "General") { + SettingsToggle( + title: "Quit GitHub Copilot when Xcode App is closed", + isOn: $quitXPCServiceOnXcodeAndAppQuit + ) + Divider() + SettingsLink( + url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + title: "Accessibility Permission", + subtitle: accessibilityPermissionSubtitle + ) + Divider() + SettingsLink( + url: "x-apple.systempreferences:com.apple.ExtensionsPreferences", + title: "Extension Permission", + subtitle: """ + Check for GitHub Copilot in Xcode's Editor menu. \ + Restart Xcode if greyed out. + """ + ) + } + .alert( + "Enable Extension Permission", + isPresented: $shouldPresentExtensionPermissionAlert + ) { + Button("Open System Preferences", action: { + let url = "x-apple.systempreferences:com.apple.ExtensionsPreferences" + NSWorkspace.shared.open(URL(string: url)!) + }).keyboardShortcut(.defaultAction) + Button("Close", role: .cancel, action: {}) + } message: { + Text("Enable GitHub Copilot under Xcode Source Editor extensions") + } + .task { + if extensionPermissionShown { return } + extensionPermissionShown = true + shouldPresentExtensionPermissionAlert = true + } + } +} + +#Preview { + GeneralSettingsView( + store: .init(initialState: .init(), reducer: { General() }) + ) +} diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index 44d63be..00bd50c 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -1,18 +1,5 @@ -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 @@ -21,381 +8,35 @@ struct GeneralView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { - AppInfoView(viewModel: viewModel, store: store) - GeneralSettingsView(store: store) - CopilotConnectionView(viewModel: viewModel, store: store) - .padding(.bottom, 20) + generalView.padding(20) Divider() - Spacer().frame(height: 40) - rightsView - .padding(.horizontal, 20) - .padding(.bottom, 20) + rightsView.padding(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 { - 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: .center, spacing: 16) { - Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) - .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: { - updateChecker.checkForUpdates() - }) { - HStack(spacing: 2) { - Text(StringConstants.checkForUpdates) - } - } - 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 { + .task { if isPreview { return } + await store.send(.appear).finish() viewModel.checkStatus() } } -} - -struct GeneralSettingsView: View { - - class Settings: ObservableObject { - @AppStorage(\.quitXPCServiceOnXcodeAndAppQuit) - var quitXPCServiceOnXcodeAndAppQuit - } - - @StateObject var settings = Settings() - @Environment(\.updateChecker) var updateChecker - @AppStorage(\.realtimeSuggestionToggle) var isCopilotEnabled: Bool - @AppStorage(\.extensionPermissionShown) var extensionPermissionShown: Bool - @State private var shouldPresentExtensionPermissionAlert = false - @State private var shouldPresentTurnoffSheet = false - - let store: StoreOf - - var body: some View { - VStack(alignment: .leading) { - Text(StringConstants.general) - .bold() - .padding(.leading, 8) - VStack(spacing: .zero) { - HStack(alignment: .center) { - Text(StringConstants.quitCopilot) - .padding(.horizontal, 8) - Spacer() - Toggle(isOn: $settings.quitXPCServiceOnXcodeAndAppQuit) { - } - .toggleStyle(.switch) - .padding(.horizontal, 8) - } - .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. Required to run. Click to open System Preferences." - }() - Text(StringConstants.accessibilityPermission) - .font(.body) - Text("\(StringConstants.status) \(grantedStatus)") - .font(.footnote) - } - Spacer() - Image(systemName: "chevron.right") - } - } - .foregroundStyle(.primary) - .padding(8) - - Divider() - Link(destination: URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences")!) { - HStack { - VStack(alignment: .leading) { - Text(StringConstants.extensionPermission) - .font(.body) - Text(""" - Check for GitHub Copilot in Xcode's Editor menu. \ - Restart Xcode if greyed out. - """) - .font(.footnote) - } - Spacer() - Image(systemName: "chevron.right") - } - } - .foregroundStyle(.primary) - .padding(.horizontal, 8) - .padding(.vertical, 8) - } - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - - HStack(alignment: .center) { - Spacer() - Button(action: { - if isCopilotEnabled { - shouldPresentTurnoffSheet = true - } else { - isCopilotEnabled = true - } - }) { - Text(isCopilotEnabled ? StringConstants.turnOffCopilot : StringConstants.turnOnCopilot) - .padding(.horizontal, 8) - } - } - } - .padding(.horizontal, 20) - .alert( - "Enable Extension Permission", - isPresented: $shouldPresentExtensionPermissionAlert - ) { - Button("Open System Preferences", action: { - let url = "x-apple.systempreferences:com.apple.ExtensionsPreferences" - NSWorkspace.shared.open(URL(string: url)!) - }).keyboardShortcut(.defaultAction) - Button("Close", role: .cancel, action: {}) - } message: { - Text("Enable GitHub Copilot under Xcode Source Editor extensions") - } - .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 - } - ) - } - .task { - if extensionPermissionShown { return } - extensionPermissionShown = true - shouldPresentExtensionPermissionAlert = true + private var generalView: some View { + VStack(alignment: .leading, spacing: 30) { + AppInfoView(viewModel: viewModel, store: store) + GeneralSettingsView(store: store) + CopilotConnectionView(viewModel: viewModel, store: store) } } -} - -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 { - WithPerceptionTracking { - VStack { - connection - .padding(.bottom, 20) - copilotResources - } - } - } - - var connection: some View { - VStack(alignment: .leading) { - Text(StringConstants.copilotConnection) - .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() - } - } - .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: "chevron.right") - } - } - .foregroundStyle(.primary) - .padding(.horizontal, 8) - .padding(.vertical, 10) - } - .background(Color.gray.opacity(0.1)) - .cornerRadius(6) - } - .padding(.horizontal, 20) - .onAppear { - 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: "chevron.right") - } - } - .foregroundStyle(.primary) - .padding(.horizontal, 8) - .padding(.vertical, 10) - - Divider() - - 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: "chevron.right") - } - } - .foregroundStyle(.primary) - .padding(.horizontal, 8) - .padding(.vertical, 10) - } - .background(Color.gray.opacity(0.1)) - .cornerRadius(6) - } - .padding(.horizontal, 20) + private var rightsView: some View { + Text("GitHub. All rights reserved.") + .font(.caption2) + .foregroundColor(.secondary.opacity(0.5)) } } -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 - } +#Preview { + GeneralView(store: .init(initialState: .init(), reducer: { General() })) + .frame(width: 800, height: 600) } - -struct GeneralView_Previews: PreviewProvider { - static var previews: some View { - GeneralView(store: .init(initialState: .init(), reducer: { General() })) - .frame(height: 800) - } -} - diff --git a/Core/Sources/HostApp/GitHubCopilotViewModel.swift b/Core/Sources/HostApp/GitHubCopilotViewModel.swift index a06bad3..9f86034 100644 --- a/Core/Sources/HostApp/GitHubCopilotViewModel.swift +++ b/Core/Sources/HostApp/GitHubCopilotViewModel.swift @@ -1,9 +1,11 @@ import GitHubCopilotService import ComposableArchitecture -import KeyboardShortcuts -import LaunchAgentManager import SwiftUI +struct SignInResponse { + let userCode: String + let verificationURL: URL +} @MainActor class GitHubCopilotViewModel: ObservableObject { diff --git a/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift b/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift deleted file mode 100644 index 9c20b7d..0000000 --- a/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation -import Preferences -import SwiftUI - -public struct CodeHighlightThemePicker: View { - public enum Scenario { - case suggestion - case promptToCode - case chat - } - - let scenario: Scenario - - public init(scenario: Scenario) { - self.scenario = scenario - } - - public var body: some View { - switch scenario { - case .suggestion: - SuggestionThemePicker() - case .promptToCode: - PromptToCodeThemePicker() - case .chat: - ChatThemePicker() - } - } - - struct SuggestionThemePicker: View { - @AppStorage(\.syncSuggestionHighlightTheme) var sync: Bool - var body: some View { - SyncToggle(sync: $sync) - } - } - - struct PromptToCodeThemePicker: View { - @AppStorage(\.syncPromptToCodeHighlightTheme) var sync: Bool - var body: some View { - SyncToggle(sync: $sync) - } - } - - struct ChatThemePicker: View { - @AppStorage(\.syncChatCodeHighlightTheme) var sync: Bool - var body: some View { - SyncToggle(sync: $sync) - } - } - - struct SyncToggle: View { - @Binding var sync: Bool - - var body: some View { - VStack(alignment: .leading) { - Toggle(isOn: $sync) { - Text("Sync color scheme with Xcode") - } - - Text("To refresh the theme, you must activate the extension service app once.") - .font(.footnote) - .foregroundColor(.secondary) - } - } - } -} - -#Preview { - @State var sync = false - return CodeHighlightThemePicker.SyncToggle(sync: $sync) -} - diff --git a/Core/Sources/HostApp/SharedComponents/SettingsButtonRow.swift b/Core/Sources/HostApp/SharedComponents/SettingsButtonRow.swift new file mode 100644 index 0000000..fa35afb --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/SettingsButtonRow.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct SettingsButtonRow: View { + let title: String + let subtitle: String? + @ViewBuilder let content: () -> Content + + var body: some View { + HStack(alignment: .center, spacing: 8) { + VStack(alignment: .leading) { + Text(title) + .font(.body) + if let subtitle = subtitle { + Text(subtitle) + .font(.footnote) + } + } + Spacer() + content() + } + .foregroundStyle(.primary) + .padding(10) + } +} + +#Preview { + SettingsButtonRow( + title: "Example", + subtitle: "This is an example" + ) { + Button("Button") { } + Button("Button") { } + } +} diff --git a/Core/Sources/HostApp/SharedComponents/SettingsLink.swift b/Core/Sources/HostApp/SharedComponents/SettingsLink.swift new file mode 100644 index 0000000..b3c00cf --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/SettingsLink.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct SettingsLink: View { + let url: URL + let title: String + let subtitle: String? + + init(_ url: URL, title: String, subtitle: String? = nil) { + self.url = url + self.title = title + self.subtitle = subtitle + } + + init(url: String, title: String, subtitle: String? = nil) { + self.init(URL(string: url)!, title: title, subtitle: subtitle) + } + + var body: some View { + Link(destination: url) { + VStack(alignment: .leading) { + Text(title) + .font(.body) + if let subtitle = subtitle { + Text(subtitle) + .font(.footnote) + } + } + Spacer() + Image(systemName: "chevron.right") + } + .foregroundStyle(.primary) + .padding(10) + } +} + +#Preview { + SettingsLink( + url: "https://example.com", + title: "Example", + subtitle: "This is an example" + ) +} diff --git a/Core/Sources/HostApp/SharedComponents/SettingsSection.swift b/Core/Sources/HostApp/SharedComponents/SettingsSection.swift new file mode 100644 index 0000000..fe679e2 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/SettingsSection.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct SettingsSection: View { + let title: String + @ViewBuilder let content: () -> Content + @ViewBuilder let footer: () -> Footer + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .bold() + .padding(.horizontal, 10) + VStack(alignment: .leading, spacing: 0) { + content() + } + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + footer() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +extension SettingsSection where Footer == EmptyView { + init(title: String, @ViewBuilder content: @escaping () -> Content) { + self.init(title: title, content: content, footer: { EmptyView() }) + } +} + +#Preview { + VStack(spacing: 20) { + SettingsSection(title: "General") { + SettingsLink( + url: "https://github.com", title: "GitHub", subtitle: "footnote") + Divider() + SettingsToggle(title: "Example", isOn: .constant(true)) + Divider() + SettingsLink(url: "https://example.com", title: "Example") + } + SettingsSection(title: "Advanced") { + SettingsLink(url: "https://example.com", title: "Example") + } footer: { + Text("Footer") + } + } + .padding() + .frame(width: 300) +} diff --git a/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift b/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift new file mode 100644 index 0000000..580ef88 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/SettingsTextField.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct SettingsTextField: View { + let title: String + let prompt: String + @Binding var text: String + + var body: some View { + Form { + TextField(text: $text, prompt: Text(prompt)) { + Text(title) + } + .textFieldStyle(PlainTextFieldStyle()) + .multilineTextAlignment(.trailing) + } + .padding(10) + } +} + +struct SettingsSecureField: View { + let title: String + let prompt: String + @Binding var text: String + + var body: some View { + Form { + SecureField(text: $text, prompt: Text(prompt)) { + Text(title) + } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + } + .padding(10) + } +} + +#Preview { + VStack(spacing: 10) { + SettingsTextField( + title: "Username", + prompt: "user", + text: .constant("") + ) + Divider() + SettingsSecureField( + title: "Password", + prompt: "pass", + text: .constant("") + ) + } + .padding(.vertical, 10) +} diff --git a/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift new file mode 100644 index 0000000..af68146 --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/SettingsToggle.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct SettingsToggle: View { + let title: String + let isOn: Binding + + var body: some View { + HStack(alignment: .center) { + Text(title) + Spacer() + Toggle(isOn: isOn) {} + .toggleStyle(.switch) + } + .padding(10) + } +} + +#Preview { + SettingsToggle(title: "Test", isOn: .constant(true)) +} diff --git a/Core/Sources/HostApp/SharedComponents/SubSection.swift b/Core/Sources/HostApp/SharedComponents/SubSection.swift deleted file mode 100644 index b294e3e..0000000 --- a/Core/Sources/HostApp/SharedComponents/SubSection.swift +++ /dev/null @@ -1,132 +0,0 @@ -import SwiftUI - -struct SubSection: View { - let title: Title - let description: Description - @ViewBuilder let content: () -> Content - - init(title: Title, description: Description, @ViewBuilder content: @escaping () -> Content) { - self.title = title - self.description = description - self.content = content - } - - var body: some View { - VStack(alignment: .leading) { - if !(title is EmptyView && description is EmptyView) { - VStack(alignment: .leading, spacing: 8) { - title - .font(.system(size: 14).weight(.semibold)) - - description - .multilineTextAlignment(.leading) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - if !(title is EmptyView && description is EmptyView) { - Divider().padding(.bottom, 4) - } - - content() - } - .padding() - .background { - RoundedRectangle(cornerRadius: 8) - .fill(Color.secondary.opacity(0.1)) - } - .overlay { - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color.secondary.opacity(0.2)) - } - } -} - -extension SubSection where Description == Text { - init(title: Title, description: String, @ViewBuilder content: @escaping () -> Content) { - self.init(title: title, description: Text(description), content: content) - } -} - -extension SubSection where Description == EmptyView { - init(title: Title, @ViewBuilder content: @escaping () -> Content) { - self.init(title: title, description: EmptyView(), content: content) - } -} - -extension SubSection where Title == EmptyView { - init(description: Description, @ViewBuilder content: @escaping () -> Content) { - self.init(title: EmptyView(), description: description, content: content) - } -} - -extension SubSection where Title == EmptyView, Description == EmptyView { - init(@ViewBuilder content: @escaping () -> Content) { - self.init(title: EmptyView(), description: EmptyView(), content: content) - } -} - -extension SubSection where Title == EmptyView, Description == Text { - init(description: String, @ViewBuilder content: @escaping () -> Content) { - self.init(title: EmptyView(), description: description, content: content) - } -} - -#Preview("Sub Section Default Style") { - SubSection(title: Text("Title"), description: "Description") { - Toggle(isOn: .constant(true), label: { - Text("Label") - }) - - Toggle(isOn: .constant(true), label: { - Text("Label") - }) - - Picker("Label", selection: .constant(0)) { - Text("Label").tag(0) - Text("Label").tag(1) - Text("Label").tag(2) - } - } - .padding() -} - -#Preview("Sub Section No Title") { - SubSection(description: "Description") { - Toggle(isOn: .constant(true), label: { - Text("Label") - }) - - Toggle(isOn: .constant(true), label: { - Text("Label") - }) - - Picker("Label", selection: .constant(0)) { - Text("Label").tag(0) - Text("Label").tag(1) - Text("Label").tag(2) - } - } - .padding() -} - -#Preview("Sub Section No Title or Description") { - SubSection { - Toggle(isOn: .constant(true), label: { - Text("Label") - }) - - Toggle(isOn: .constant(true), label: { - Text("Label") - }) - - Picker("Label", selection: .constant(0)) { - Text("Label").tag(0) - Text("Label").tag(1) - Text("Label").tag(2) - } - } - .padding() -} - diff --git a/Core/Sources/HostApp/StringConstants.swift b/Core/Sources/HostApp/StringConstants.swift deleted file mode 100644 index ca47928..0000000 --- a/Core/Sources/HostApp/StringConstants.swift +++ /dev/null @@ -1,52 +0,0 @@ -struct StringConstants { - // General Tab Strings - 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 accessibilityPermission = "Accessibility Permission" - static let extensionPermission = "Extension Permission" - 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 copilotConnection = "Copilot Connection" - 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.." - -// Feature Tab Settings Strings - static let suggestionSettings = "Suggestion Settings" - static let requestSuggestionsInRealTime = "Request suggestions in real-time" - static let acceptSuggestionsWithTab = "Accept suggestions with Tab" - static let disabledLanguageList = "Disabled language list" - - // Proxy String - static let enterprise = "Enterprise" - static let leaveBlankPrompt = "Leave it blank if none is available." - static let authProviderURL = "Auth provider URL" - static let proxy = "Proxy" - static let proxyURLPrompt = "http://host:port" - static let proxyURL = "Proxy URL" - static let proxyUsernamePrompt = "username" - static let proxyUsername = "Proxy username" - static let proxyPasswordPrompt = "password" - static let proxyPassword = "Proxy password" - static let proxyStrictSSL = "Proxy strict SSL" - static let refreshConfigurations = "Refresh configurations" -} diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index b60cc48..069a410 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -38,7 +38,7 @@ public struct TabContainer: View { image: "CopilotLogo", isSystemImage: false ) - FeatureSettingsView().tabBarItem( + AdvancedSettings().tabBarItem( tag: 2, title: "Advanced", image: "gearshape.2.fill" diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift index 17ce20f..2dfa269 100644 --- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift +++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift @@ -1,4 +1,5 @@ import Foundation +import Logger import ServiceManagement public struct LaunchAgentManager { @@ -35,9 +36,11 @@ public struct LaunchAgentManager { public func setupLaunchAgent() async throws { if #available(macOS 13, *) { + Logger.client.info("Registering bridge launch agent") let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") try bridgeLaunchAgent.register() } else { + Logger.client.info("Creating and loading bridge launch agent") let content = """ @@ -79,9 +82,11 @@ public struct LaunchAgentManager { public func removeLaunchAgent() async throws { if #available(macOS 13, *) { + Logger.client.info("Unregistering bridge launch agent") let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") try await bridgeLaunchAgent.unregister() } else { + Logger.client.info("Unloading and removing bridge launch agent") try await launchctl("unload", launchAgentPath) try FileManager.default.removeItem(atPath: launchAgentPath) } @@ -89,6 +94,7 @@ public struct LaunchAgentManager { public func reloadLaunchAgent() async throws { if #unavailable(macOS 13) { + Logger.client.info("Reloading bridge launch agent") try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier) } } @@ -97,6 +103,7 @@ public struct LaunchAgentManager { if #available(macOS 13, *) { let path = launchAgentPath if FileManager.default.fileExists(atPath: path) { + Logger.client.info("Unloading and removing old bridge launch agent") try? await launchctl("unload", path) try? FileManager.default.removeItem(atPath: path) } @@ -106,6 +113,7 @@ public struct LaunchAgentManager { with: "XPCService" ) if FileManager.default.fileExists(atPath: path) { + Logger.client.info("Removing old bridge launch agent plist") try? FileManager.default.removeItem(atPath: path) } } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 1cffcc7..2228a1d 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -196,6 +196,13 @@ public class XPCService: NSObject, XPCServiceProtocol { NotificationCenter.default.post(name: .init(name), object: nil) } + public func quit(reply: @escaping () -> Void) { + Task { + await Service.shared.prepareForExit() + reply() + } + } + // MARK: - Requests public func send( diff --git a/Server/package-lock.json b/Server/package-lock.json index b3acac4..dc70180 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,13 +8,13 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.238.0" + "@github/copilot-language-server": "^1.239.0" } }, "node_modules/@github/copilot-language-server": { - "version": "1.238.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.238.0.tgz", - "integrity": "sha512-pkNiTUc4o1KKwlFvSAnG97Mz6PSAhG7LTXwzGetkYr4vZySwZ1ne2SU3Cfqn+ndTlw/I03W0MuG3DjENk/GFiQ==", + "version": "1.239.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.239.0.tgz", + "integrity": "sha512-9hc4yKSVJQuE+BAdoP9EhOAFv0FlZG1XrzxJ9tPZvf65EADPbdPpZlgLtUDldKur6hQTdBSDhSbbBY9X8qJr7A==", "bin": { "copilot-language-server": "dist/language-server.js" } diff --git a/Server/package.json b/Server/package.json index c36ed20..7ebdb0d 100644 --- a/Server/package.json +++ b/Server/package.json @@ -4,6 +4,6 @@ "description": "Package for downloading @github/copilot-language-server", "private": true, "dependencies": { - "@github/copilot-language-server": "^1.238.0" + "@github/copilot-language-server": "^1.239.0" } } diff --git a/Tool/Sources/Logger/FileLogger.swift b/Tool/Sources/Logger/FileLogger.swift index e7c2a64..14d9ff8 100644 --- a/Tool/Sources/Logger/FileLogger.swift +++ b/Tool/Sources/Logger/FileLogger.swift @@ -33,7 +33,11 @@ final class FileLogger { } actor FileLoggerImplementation { + #if DEBUG + private let logBaseName = "github-copilot-for-xcode-dev" + #else private let logBaseName = "github-copilot-for-xcode" + #endif private let logExtension = "log" private let maxLogSize = 5_000_000 private let logOverflowLimit = 5_000_000 * 2 diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index 649e504..518fec1 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -23,6 +23,7 @@ public final class Logger { public static let license = Logger(category: "License") public static let `extension` = Logger(category: "Extension") public static let communicationBridge = Logger(category: "CommunicationBridge") + public static let workspacePool = Logger(category: "WorkspacePool") public static let debug = Logger(category: "Debug") #if DEBUG /// Use a temp logger to log something temporary. I won't be available in release builds. diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift index 2b9a073..9662785 100644 --- a/Tool/Sources/Workspace/WorkspacePool.swift +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -1,5 +1,6 @@ import Dependencies import Foundation +import Logger import XcodeInspector public struct WorkspacePoolDependencyKey: DependencyKey { @@ -61,12 +62,12 @@ public class WorkspacePool { } public func fetchFilespaceIfExisted(fileURL: URL) -> Filespace? { - for workspace in workspaces.values { - if let filespace = workspace.filespaces[fileURL] { - return filespace - } - } - return nil + let filespaces = workspaces.values.compactMap { $0.filespaces[fileURL] } + if filespaces.isEmpty { return nil } + if filespaces.count == 1 { return filespaces.first } + Logger.workspacePool.info("Multiple workspaces found with file: \(fileURL)") + // If multiple workspaces are found, return the first with a suggestion + return filespaces.first { $0.presentingSuggestion != nil } } @WorkspaceActor diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index 00f8254..7f395aa 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -162,6 +162,16 @@ public class XPCExtensionService { ) } + + public func quitService() async throws { + try await withXPCServiceConnectedWithoutLaunching { + service, continuation in + service.quit { + continuation.resume(()) + } + } + } + public func postNotification(name: String) async throws { try await withXPCServiceConnected { service, continuation in @@ -254,6 +264,20 @@ extension XPCExtensionService { } } } + + @XPCServiceActor + private func withXPCServiceConnectedWithoutLaunching( + _ fn: @escaping (XPCServiceProtocol, AutoFinishContinuation) -> Void + ) async throws -> T { + if let service, let connection = service.connection { + do { + return try await XPCShared.withXPCServiceConnected(connection: connection, fn) + } catch { + throw XPCExtensionServiceError.xpcServiceError(error) + } + } + throw XPCExtensionServiceError.failedToCreateXPCConnection + } @XPCServiceActor private func suggestionRequest( diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index 0c37d8c..9612057 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -56,6 +56,7 @@ public protocol XPCServiceProtocol { func getXPCServiceAccessibilityPermission(withReply reply: @escaping (Bool) -> Void) func postNotification(name: String, withReply reply: @escaping () -> Void) func send(endpoint: String, requestBody: Data, reply: @escaping (Data?, Error?) -> Void) + func quit(reply: @escaping () -> Void) } public struct NoResponse: Codable {